- fix: "Log leeren" without account filter used a plain DELETE over millions of rows. That held the sqlite write lock long enough that the scheduler's own log writes started failing with "database is locked", and the request itself eventually 500'd back as HTML "Internal Server Error" — which the frontend then tried to JSON.parse, producing the popup the user reported. Now uses DROP+CREATE for full clears (near-instant) and wal_checkpoint to reclaim space; per-account clear still uses a normal DELETE. - Raise sqlite busy_timeout from 10s to 60s so a legitimately slow writer no longer starves smaller ones. - retry-failed: single bulk DELETE with mail_uid IN (...) per account instead of one query per uid. - Both endpoints now return proper HTTPException(500, ...) with detail instead of letting the exception bubble as plain text. - Frontend: parseResponse() reads text first, then tries JSON. Both clearLogs() and retryFailed() surface the actual server message instead of crashing on non-JSON responses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
306 lines
12 KiB
HTML
306 lines
12 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Verarbeitungslog — IMAP Mail Filter{% endblock %}
|
|
{% block content %}
|
|
<h1>Verarbeitungslog</h1>
|
|
|
|
<div style="display:flex; gap:1rem; align-items:end; flex-wrap:wrap; margin-bottom:0.5rem;">
|
|
<label style="margin-bottom:0;">
|
|
Konto
|
|
<select id="log-account" onchange="loadLogs()" style="margin-bottom:0;">
|
|
<option value="">Alle Konten</option>
|
|
{% for acc in accounts %}
|
|
<option value="{{ acc.id }}">{{ acc.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<label style="margin-bottom:0;">
|
|
Level
|
|
<select id="log-level" onchange="loadLogs()" style="margin-bottom:0;">
|
|
<option value="">Alle</option>
|
|
<option value="success">Erfolg</option>
|
|
<option value="info">Info</option>
|
|
<option value="warning">Warnung</option>
|
|
<option value="error">Fehler</option>
|
|
</select>
|
|
</label>
|
|
<label style="margin-bottom:0; flex:1; min-width:200px;">
|
|
Schnellsuche
|
|
<input type="search" id="log-search" placeholder="überall suchen..." style="margin-bottom:0;" onkeydown="if(event.key==='Enter') loadLogs()">
|
|
</label>
|
|
<div role="group" style="margin-bottom:0;">
|
|
<button class="outline small" onclick="loadLogs()">Suchen</button>
|
|
<button class="outline small" onclick="retryFailed()" title="Löscht den 'verarbeitet'-Marker für Mails, die einen Fetch-Fehler hatten, damit sie beim nächsten Poll neu versucht werden">Fehlgeschlagene neu einlesen</button>
|
|
<button class="outline small contrast" onclick="clearLogs()">Log leeren</button>
|
|
</div>
|
|
<label style="margin-bottom:0;">
|
|
<input type="checkbox" id="auto-refresh" onchange="toggleAutoRefresh()" style="margin-bottom:0;">
|
|
Auto-Refresh (5s)
|
|
</label>
|
|
</div>
|
|
|
|
<details style="margin-bottom:1rem;">
|
|
<summary>Erweiterte Suche (UND-verknüpft)</summary>
|
|
<div class="grid" style="margin-top:0.5rem;">
|
|
<label style="margin-bottom:0;">
|
|
Betreff
|
|
<input type="search" id="search-subject" placeholder="z.B. Rechnung" style="margin-bottom:0;" onkeydown="if(event.key==='Enter') loadLogs()">
|
|
</label>
|
|
<label style="margin-bottom:0;">
|
|
Absender
|
|
<input type="search" id="search-from" placeholder="z.B. shop@example.com" style="margin-bottom:0;" onkeydown="if(event.key==='Enter') loadLogs()">
|
|
</label>
|
|
<label style="margin-bottom:0;">
|
|
Regel
|
|
<input type="search" id="search-rule" placeholder="z.B. Newsletter" style="margin-bottom:0;" onkeydown="if(event.key==='Enter') loadLogs()">
|
|
</label>
|
|
</div>
|
|
<div class="grid" style="margin-top:0.5rem;">
|
|
<label style="margin-bottom:0;">
|
|
Nachricht
|
|
<input type="search" id="search-message" placeholder="z.B. Aktion ausgeführt" style="margin-bottom:0;" onkeydown="if(event.key==='Enter') loadLogs()">
|
|
</label>
|
|
<label style="margin-bottom:0;">
|
|
Details
|
|
<input type="search" id="search-details" placeholder="z.B. TREFFER" style="margin-bottom:0;" onkeydown="if(event.key==='Enter') loadLogs()">
|
|
</label>
|
|
<label style="margin-bottom:0;">
|
|
Ordner
|
|
<input type="search" id="search-folder" placeholder="z.B. INBOX" style="margin-bottom:0;" onkeydown="if(event.key==='Enter') loadLogs()">
|
|
</label>
|
|
</div>
|
|
<div style="margin-top:0.5rem;">
|
|
<button class="outline small" onclick="loadLogs()">Erweitert suchen</button>
|
|
<button class="outline small contrast" onclick="clearAdvancedSearch()">Felder leeren</button>
|
|
</div>
|
|
</details>
|
|
|
|
<div id="log-stats" style="margin-bottom:1rem;"></div>
|
|
|
|
<div id="log-container">
|
|
<p aria-busy="true">Logs werden geladen...</p>
|
|
</div>
|
|
|
|
<div id="log-paging" style="display:flex; justify-content:center; gap:1rem; margin-top:1rem;"></div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<style>
|
|
.log-table {
|
|
width: 100%;
|
|
font-size: 0.9rem;
|
|
}
|
|
.log-table th, .log-table td {
|
|
padding: 0.4rem 0.6rem;
|
|
vertical-align: top;
|
|
}
|
|
.log-row { border-left: 3px solid transparent; }
|
|
.log-row[data-level="success"] { border-left-color: #28a745; }
|
|
.log-row[data-level="info"] { border-left-color: #17a2b8; }
|
|
.log-row[data-level="warning"] { border-left-color: #ffc107; }
|
|
.log-row[data-level="error"] { border-left-color: #dc3545; }
|
|
|
|
.log-level {
|
|
display: inline-block;
|
|
padding: 0.1rem 0.4rem;
|
|
border-radius: 3px;
|
|
font-size: 0.75rem;
|
|
font-weight: bold;
|
|
text-transform: uppercase;
|
|
}
|
|
.log-level-success { background: #d4edda; color: #155724; }
|
|
.log-level-info { background: #d1ecf1; color: #0c5460; }
|
|
.log-level-warning { background: #fff3cd; color: #856404; }
|
|
.log-level-error { background: #f8d7da; color: #721c24; }
|
|
|
|
.log-detail { font-size: 0.8rem; opacity: 0.7; }
|
|
.log-mail-info { font-size: 0.8rem; color: var(--pico-muted-color); }
|
|
</style>
|
|
<script>
|
|
let currentOffset = 0;
|
|
const PAGE_SIZE = 50;
|
|
let refreshTimer = null;
|
|
|
|
async function loadLogs(offset = 0) {
|
|
currentOffset = offset;
|
|
const accountId = document.getElementById('log-account').value;
|
|
const level = document.getElementById('log-level').value;
|
|
const container = document.getElementById('log-container');
|
|
|
|
const search = document.getElementById('log-search').value.trim();
|
|
const searchSubject = document.getElementById('search-subject').value.trim();
|
|
const searchFrom = document.getElementById('search-from').value.trim();
|
|
const searchRule = document.getElementById('search-rule').value.trim();
|
|
const searchMessage = document.getElementById('search-message').value.trim();
|
|
const searchDetails = document.getElementById('search-details').value.trim();
|
|
const searchFolder = document.getElementById('search-folder').value.trim();
|
|
|
|
let url = `/api/logs/?limit=${PAGE_SIZE}&offset=${offset}`;
|
|
if (accountId) url += `&account_id=${accountId}`;
|
|
if (level) url += `&level=${level}`;
|
|
if (search) url += `&search=${encodeURIComponent(search)}`;
|
|
if (searchSubject) url += `&search_subject=${encodeURIComponent(searchSubject)}`;
|
|
if (searchFrom) url += `&search_from=${encodeURIComponent(searchFrom)}`;
|
|
if (searchRule) url += `&search_rule=${encodeURIComponent(searchRule)}`;
|
|
if (searchMessage) url += `&search_message=${encodeURIComponent(searchMessage)}`;
|
|
if (searchDetails) url += `&search_details=${encodeURIComponent(searchDetails)}`;
|
|
if (searchFolder) url += `&search_folder=${encodeURIComponent(searchFolder)}`;
|
|
|
|
try {
|
|
const resp = await fetch(url);
|
|
const data = await resp.json();
|
|
renderStats(data.total);
|
|
renderLogs(data.logs);
|
|
renderPaging(data.total, offset);
|
|
} catch(e) {
|
|
container.innerHTML = `<p>Fehler beim Laden: ${e.message}</p>`;
|
|
}
|
|
}
|
|
|
|
function renderStats(total) {
|
|
document.getElementById('log-stats').innerHTML = `<small>${total} Einträge gesamt</small>`;
|
|
}
|
|
|
|
function renderLogs(logs) {
|
|
const container = document.getElementById('log-container');
|
|
if (!logs.length) {
|
|
container.innerHTML = '<article><p>Keine Log-Einträge vorhanden.</p></article>';
|
|
return;
|
|
}
|
|
|
|
let html = `<table class="log-table">
|
|
<thead><tr>
|
|
<th style="width:140px;">Zeit</th>
|
|
<th style="width:60px;">Level</th>
|
|
<th style="width:120px;">Konto</th>
|
|
<th>Nachricht</th>
|
|
<th style="width:120px;">Regel</th>
|
|
</tr></thead><tbody>`;
|
|
|
|
for (const log of logs) {
|
|
const time = log.created_at ? new Date(log.created_at).toLocaleString('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
|
}) : '';
|
|
|
|
const levelClass = `log-level-${log.level}`;
|
|
const levelLabel = {success: 'OK', info: 'Info', warning: 'Warn', error: 'Fehler'}[log.level] || log.level;
|
|
|
|
let message = escapeHtml(log.message);
|
|
if (log.mail_subject || log.mail_from) {
|
|
message += `<div class="log-mail-info">`;
|
|
if (log.mail_from) message += `Von: ${escapeHtml(log.mail_from)}<br>`;
|
|
if (log.mail_subject) message += `Betreff: ${escapeHtml(log.mail_subject)}`;
|
|
message += `</div>`;
|
|
}
|
|
if (log.details) {
|
|
message += `<div class="log-detail">${escapeHtml(log.details)}</div>`;
|
|
}
|
|
|
|
html += `<tr class="log-row" data-level="${log.level}">
|
|
<td><small>${time}</small></td>
|
|
<td><span class="log-level ${levelClass}">${levelLabel}</span></td>
|
|
<td><small>${escapeHtml(log.account_name || '')}</small></td>
|
|
<td>${message}</td>
|
|
<td><small>${escapeHtml(log.rule_name || '')}</small></td>
|
|
</tr>`;
|
|
}
|
|
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function renderPaging(total, offset) {
|
|
const paging = document.getElementById('log-paging');
|
|
if (total <= PAGE_SIZE) {
|
|
paging.innerHTML = '';
|
|
return;
|
|
}
|
|
const page = Math.floor(offset / PAGE_SIZE) + 1;
|
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
|
let html = '';
|
|
if (offset > 0) {
|
|
html += `<button class="outline small" onclick="loadLogs(${offset - PAGE_SIZE})">Neuere</button>`;
|
|
}
|
|
html += `<small>Seite ${page} / ${totalPages}</small>`;
|
|
if (offset + PAGE_SIZE < total) {
|
|
html += `<button class="outline small" onclick="loadLogs(${offset + PAGE_SIZE})">Ältere</button>`;
|
|
}
|
|
paging.innerHTML = html;
|
|
}
|
|
|
|
async function parseResponse(resp) {
|
|
// Liest die Response robust: JSON wenn möglich, sonst als Text.
|
|
const text = await resp.text();
|
|
try { return {ok: resp.ok, status: resp.status, data: JSON.parse(text)}; }
|
|
catch { return {ok: resp.ok, status: resp.status, data: null, text}; }
|
|
}
|
|
|
|
async function clearLogs() {
|
|
if (!confirm('Log wirklich leeren?')) return;
|
|
const accountId = document.getElementById('log-account').value;
|
|
let url = '/api/logs/';
|
|
if (accountId) url += `?account_id=${accountId}`;
|
|
try {
|
|
const resp = await fetch(url, {method: 'DELETE'});
|
|
const r = await parseResponse(resp);
|
|
if (!r.ok) {
|
|
const msg = r.data?.detail || r.text || `HTTP ${r.status}`;
|
|
alert('Fehler beim Leeren: ' + msg);
|
|
return;
|
|
}
|
|
loadLogs();
|
|
} catch(e) {
|
|
alert('Netzwerk-Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function retryFailed() {
|
|
if (!confirm('Alle Mails mit "Fehler beim Abrufen" für den nächsten Poll neu vormerken?')) return;
|
|
const accountId = document.getElementById('log-account').value;
|
|
let url = '/api/filters/retry-failed';
|
|
if (accountId) url += `?account_id=${accountId}`;
|
|
try {
|
|
const resp = await fetch(url, {method: 'POST'});
|
|
const r = await parseResponse(resp);
|
|
if (!r.ok) {
|
|
const msg = r.data?.detail || r.text || `HTTP ${r.status}`;
|
|
alert('Fehler bei retry-failed: ' + msg);
|
|
return;
|
|
}
|
|
const d = r.data || {};
|
|
alert(`${d.unique_uids || 0} UID(s) neu vorgemerkt (${d.reset || 0} ProcessedMail-Einträge gelöscht). Werden beim nächsten Poll neu versucht.`);
|
|
} catch(e) {
|
|
alert('Netzwerk-Fehler: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function toggleAutoRefresh() {
|
|
if (document.getElementById('auto-refresh').checked) {
|
|
refreshTimer = setInterval(() => loadLogs(currentOffset), 5000);
|
|
} else {
|
|
clearInterval(refreshTimer);
|
|
refreshTimer = null;
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function clearAdvancedSearch() {
|
|
document.getElementById('search-subject').value = '';
|
|
document.getElementById('search-from').value = '';
|
|
document.getElementById('search-rule').value = '';
|
|
document.getElementById('search-message').value = '';
|
|
document.getElementById('search-details').value = '';
|
|
document.getElementById('search-folder').value = '';
|
|
loadLogs();
|
|
}
|
|
|
|
// Initial load
|
|
loadLogs();
|
|
</script>
|
|
{% endblock %}
|