For senders whose mail body is the receipt (e.g. GoCardless payouts) there is no attachment to file. The new action "Als PDF anhängen + nach <Ordner>" renders the mail like a print from a mail client (header block From/To/Date/Subject, then the content) and replaces it in the target folder with: original content + PDF + the original mail byte-for-byte as Original.eml, so the unaltered original is retained (GoBD). - Rendering with WeasyPrint. Images are fetched by us and inlined as data: URIs; WeasyPrint itself may only read data:. Fetching is restricted to http(s), public addresses (private, loopback, link-local and PDF_BLOCKED_NETWORKS, default 172.0.0.0/12, are refused; redirects are checked hop by hop), with time and size limits. cid: images come from the MIME parts. - The replacement is APPENDed with the original flags and INTERNALDATE, verified in the target via its new Message-ID, and only then is the original deleted. Already converted mails are only moved. - "PDF-Vorschau" in the rule dialog renders the newest matching mail read-only and opens it in the browser. - List/log show 📄 targets; warnings for PDF + move/delete and for PDF + "Hat Anhang = Ja"; log folder search covers PDF targets. - Docker image gets Pango and fonts (~316 MB total). Tested with the real GoCardless mail (3 remote images, rendered both locally and inside the built image) and a simulated IMAP server for success, rejected APPEND, missing copy and already converted mails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
542 lines
26 KiB
HTML
542 lines
26 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Log — IMAP Mail Filter{% endblock %}
|
||
{% block content %}
|
||
<div class="page-head">
|
||
<h1>Log</h1>
|
||
<select id="log-account" class="head-select" onchange="reload()" title="Konto">
|
||
<option value="">Alle Konten</option>
|
||
{% for acc in accounts %}
|
||
<option value="{{ acc.id }}">{{ acc.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="log-range" class="head-select" onchange="reload()" title="Zeitraum">
|
||
<option value="1h">Letzte Stunde</option>
|
||
<option value="today">Heute</option>
|
||
<option value="7d" selected>Letzte 7 Tage</option>
|
||
<option value="30d">Letzte 30 Tage</option>
|
||
<option value="all">Gesamter Zeitraum</option>
|
||
</select>
|
||
<span id="log-stats" class="muted small-text"></span>
|
||
<label class="auto-refresh" title="Erste Seite alle 10 Sekunden neu laden">
|
||
<input type="checkbox" role="switch" id="auto-refresh" onchange="toggleAutoRefresh()"> Live
|
||
</label>
|
||
<details class="dropdown head-menu" ontoggle="if (this.open) loadDbStatus()">
|
||
<summary>⋯</summary>
|
||
<ul dir="rtl">
|
||
<li class="db-status" id="db-status">Datenbank wird geprüft…</li>
|
||
<li><a href="#" onclick="runMaintenance(); return false;" title="Alte Einträge nach Aufbewahrungsregel löschen und die Datenbankdatei verkleinern">Datenbank aufräumen & verkleinern</a></li>
|
||
<li><a href="#" onclick="retryFailed(); return false;" title="Mails mit Abruf-Fehler oder fehlgeschlagener Aktion beim nächsten Poll erneut verarbeiten">Fehlgeschlagene neu einlesen</a></li>
|
||
<li><a href="#" onclick="clearLogs(); return false;">Log leeren</a></li>
|
||
</ul>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="toolbar">
|
||
<div class="search-wrap">
|
||
<input type="search" id="log-search" autocomplete="off" spellcheck="false"
|
||
placeholder="Suchen … z.B. hetzner oder von:hetzner betreff:rechnung regel:cps"
|
||
oninput="onSearchInput()" onkeydown="if(event.key==='Enter'){ reload(); }">
|
||
<button type="button" class="help-btn" onclick="toggleHelp()" title="Suchsyntax">?</button>
|
||
</div>
|
||
<div class="seg" id="level-filter">
|
||
<button data-l="" class="active" onclick="setLevel('')">Alle</button>
|
||
<button data-l="success" onclick="setLevel('success')">✅ Aktionen</button>
|
||
<button data-l="error,warning" onclick="setLevel('error,warning')">❌ Fehler</button>
|
||
<button data-l="info" onclick="setLevel('info')">ℹ Info</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="search-help" class="search-help" hidden>
|
||
<strong>Suchsyntax</strong> — Begriffe werden UND-verknüpft, Groß/Klein egal. Mehrere Wörter in Anführungszeichen: <code>betreff:"ihre rechnung"</code>
|
||
<div class="help-grid">
|
||
<code>hetzner</code><span>Freitext in allen Feldern</span>
|
||
<code>von:hetzner.com</code><span>Absender</span>
|
||
<code>betreff:rechnung</code><span>Betreff</span>
|
||
<code>regel:cps</code><span>Name der Regel</span>
|
||
<code>ordner:Rechnungen</code><span>Ordner / Ziel</span>
|
||
<code>text:verschoben</code><span>Log-Nachricht</span>
|
||
</div>
|
||
Tipp: Klick auf Regel, Absender oder Ordner in einer Zeile fügt ihn als Filter hinzu.
|
||
</div>
|
||
|
||
<div id="active-filters" class="active-filters"></div>
|
||
|
||
<div id="log-container"><p aria-busy="true">Log wird geladen…</p></div>
|
||
<div id="log-more"></div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<style>
|
||
.muted { color:var(--pico-muted-color); }
|
||
.small-text { font-size:0.85rem; }
|
||
.page-head { display:flex; align-items:center; gap:0.6rem; flex-wrap:wrap; margin-bottom:0.75rem; }
|
||
.page-head h1 { margin:0 0.4rem 0 0; }
|
||
.head-select { width:auto; margin:0; padding-top:0.3rem; padding-bottom:0.3rem; font-size:0.9rem; }
|
||
.page-head #log-stats { flex:1; }
|
||
.auto-refresh { margin:0; font-size:0.85rem; white-space:nowrap; }
|
||
.head-menu { margin:0; }
|
||
.head-menu summary { padding:0.2rem 0.7rem; height:auto; font-size:1.1rem; line-height:1.2; }
|
||
.head-menu summary::after { display:none; }
|
||
.head-menu ul { min-width:290px; font-size:0.9rem; }
|
||
.head-menu li.db-status { direction:ltr; text-align:left; font-size:0.78rem; color:var(--pico-muted-color); line-height:1.5;
|
||
border-bottom:1px solid var(--app-border, #e3e6ea); padding-bottom:0.5rem; white-space:normal; }
|
||
|
||
.toolbar { display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap; margin-bottom:0.4rem; }
|
||
.search-wrap { position:relative; flex:1 1 360px; }
|
||
.search-wrap input { margin:0; padding-top:0.35rem; padding-bottom:0.35rem; padding-right:2.4rem; font-size:0.9rem; }
|
||
.search-wrap .help-btn { position:absolute; right:0.35rem; top:50%; transform:translateY(-50%); margin:0; padding:0 0.5rem;
|
||
font-size:0.8rem; line-height:1.6; border-radius:999px;
|
||
--pico-background-color:#eef0f3; --pico-border-color:transparent; --pico-color:#555; }
|
||
.seg { display:inline-flex; border:1px solid var(--app-border, #e3e6ea); border-radius:var(--pico-border-radius); overflow:hidden; background:#fff; }
|
||
.toolbar .seg button, .search-wrap .help-btn { transition:none; }
|
||
.toolbar .seg button { --pico-background-color:transparent; --pico-border-color:transparent; --pico-color:#374151;
|
||
margin:0; border-radius:0; padding:0.3rem 0.7rem; font-size:0.8rem; box-shadow:none; }
|
||
.toolbar .seg button + button { border-left:1px solid var(--app-border, #e3e6ea); }
|
||
.toolbar .seg button:hover { --pico-background-color:#f1f3f5; }
|
||
.toolbar .seg button.active { --pico-background-color:var(--pico-primary); --pico-color:var(--pico-primary-inverse); }
|
||
|
||
.search-help { background:#fff; border:1px solid var(--app-border, #e3e6ea); border-radius:var(--pico-border-radius);
|
||
padding:0.6rem 0.8rem; font-size:0.83rem; margin-bottom:0.5rem; }
|
||
.help-grid { display:grid; grid-template-columns:max-content 1fr; gap:0.15rem 1rem; margin:0.4rem 0; }
|
||
.help-grid code { font-size:0.8rem; padding:0 0.3rem; }
|
||
|
||
.active-filters { display:flex; flex-wrap:wrap; gap:0.3rem; margin-bottom:0.5rem; min-height:0; }
|
||
.fchip { display:inline-flex; align-items:center; gap:0.25rem; font-size:0.78rem; padding:0.05rem 0.2rem 0.05rem 0.5rem;
|
||
border-radius:999px; background:#e8eefc; color:#274b9f; border:1px solid #cdd9f5; }
|
||
.fchip button { --pico-background-color:transparent; --pico-border-color:transparent; --pico-color:#274b9f;
|
||
margin:0; padding:0 0.3rem; font-size:0.8rem; line-height:1.3; box-shadow:none; }
|
||
.fchip.clear-all { background:transparent; border-color:transparent; }
|
||
|
||
.logs { background:#fff; border:1px solid var(--app-border, #e3e6ea); border-radius:var(--pico-border-radius); overflow:hidden; font-size:0.84rem; }
|
||
.logs .l { display:grid; grid-template-columns:98px 22px minmax(110px, 170px) minmax(0, 1fr) minmax(90px, 190px);
|
||
gap:0.6rem; align-items:baseline; padding:0.3rem 0.75rem; border-bottom:1px solid #eef0f3; cursor:pointer; }
|
||
.logs .l:hover { background:#f8f9fb; }
|
||
.logs .l.open { background:#f4f7fd; }
|
||
.logs .l.head { background:#f6f7f9; cursor:default; font-size:0.72rem; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; color:var(--pico-muted-color); }
|
||
.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; }
|
||
.l-main { min-width:0; }
|
||
.l-msg { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.l-mail { font-size:0.78rem; color:var(--pico-muted-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.l-mail b { color:var(--pico-color); font-weight:500; }
|
||
.flink { color:inherit; text-decoration:none; border-bottom:1px dotted #aab; cursor:pointer; }
|
||
.flink:hover { color:var(--pico-primary); border-bottom-color:var(--pico-primary); }
|
||
.l-folder { color:#1f6f43; }
|
||
.l-detail { grid-column:1 / -1; font-size:0.78rem; background:#fff; border:1px solid #e6e9ed; border-radius:4px;
|
||
padding:0.4rem 0.6rem; margin:0.2rem 0 0.2rem; cursor:text; white-space:pre-wrap; word-break:break-word; }
|
||
.l-detail dl { display:grid; grid-template-columns:max-content 1fr; gap:0.1rem 0.8rem; margin:0; }
|
||
.l-detail dt { color:var(--pico-muted-color); }
|
||
.l-detail dd { margin:0; }
|
||
#log-more { text-align:center; margin-top:0.6rem; }
|
||
#log-more button { padding:0.3rem 1rem; font-size:0.85rem; }
|
||
|
||
@media (max-width: 800px) {
|
||
.logs .l { grid-template-columns:80px 22px minmax(0, 1fr); }
|
||
.logs .l .l-rule, .logs .l .l-folder, .logs .l.head { display:none; }
|
||
}
|
||
</style>
|
||
<script>
|
||
const PAGE_SIZE = 100;
|
||
const LEVEL_ICON = {success: '✅', info: 'ℹ️', warning: '⚠️', error: '❌'};
|
||
// Suchkürzel → API-Parameter
|
||
const KEYS = {
|
||
von: 'sender', from: 'sender', absender: 'sender',
|
||
betreff: 'subject', subject: 'subject',
|
||
regel: 'rule', rule: 'rule', filter: 'rule',
|
||
ordner: 'folder', folder: 'folder', ziel: 'folder',
|
||
text: 'message', nachricht: 'message', msg: 'message',
|
||
};
|
||
const KEY_LABEL = {sender: 'Von', subject: 'Betreff', rule: 'Regel', folder: 'Ordner', message: 'Text', q: 'Text'};
|
||
|
||
let levelFilter = '';
|
||
let logs = [];
|
||
let nextBeforeId = null;
|
||
let hasMore = false;
|
||
let inflight = null;
|
||
let searchTimer = null;
|
||
let refreshTimer = null;
|
||
|
||
function esc(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
function parseUtc(iso) {
|
||
// Server liefert naive UTC-Zeitstempel — ohne 'Z' würde der Browser sie als Ortszeit lesen
|
||
return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||
}
|
||
|
||
// ---------- Suchsyntax ----------
|
||
|
||
function tokenize(input) {
|
||
// Liefert [{key, value, raw}] — key null = Freitext
|
||
const re = /(\w+):"([^"]*)"|(\w+):(\S+)|"([^"]*)"|(\S+)/g;
|
||
const out = [];
|
||
let m;
|
||
while ((m = re.exec(input)) !== null) {
|
||
let key = m[1] || m[3] || null;
|
||
let value = m[2] ?? m[4] ?? m[5] ?? m[6] ?? '';
|
||
if (key && !KEYS[key.toLowerCase()]) { value = m[0]; key = null; } // unbekanntes Kürzel = Freitext
|
||
if (value === '') continue;
|
||
out.push({key: key ? KEYS[key.toLowerCase()] : null, value, raw: m[0]});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function serializeTokens(tokens) {
|
||
return tokens.map(t => {
|
||
if (!t.key) return /\s/.test(t.value) ? `"${t.value}"` : t.value;
|
||
const short = {sender: 'von', subject: 'betreff', rule: 'regel', folder: 'ordner', message: 'text'}[t.key];
|
||
return `${short}:${/\s/.test(t.value) ? `"${t.value}"` : t.value}`;
|
||
}).join(' ');
|
||
}
|
||
|
||
function addFilter(key, value) {
|
||
const input = document.getElementById('log-search');
|
||
const tokens = tokenize(input.value).filter(t => !(t.key === key));
|
||
tokens.push({key, value});
|
||
input.value = serializeTokens(tokens);
|
||
reload();
|
||
}
|
||
|
||
function removeToken(index) {
|
||
const input = document.getElementById('log-search');
|
||
const tokens = tokenize(input.value);
|
||
tokens.splice(index, 1);
|
||
input.value = serializeTokens(tokens);
|
||
reload();
|
||
}
|
||
|
||
function clearFilters() {
|
||
document.getElementById('log-search').value = '';
|
||
setLevel('');
|
||
}
|
||
|
||
function renderActiveFilters(tokens) {
|
||
const box = document.getElementById('active-filters');
|
||
const chips = tokens.map((t, i) =>
|
||
`<span class="fchip">${esc(KEY_LABEL[t.key || 'q'])}: <b>${esc(t.value)}</b><button title="Filter entfernen" onclick="removeToken(${i})">✕</button></span>`);
|
||
if (levelFilter) {
|
||
const label = document.querySelector(`#level-filter button[data-l="${levelFilter}"]`)?.textContent || levelFilter;
|
||
chips.push(`<span class="fchip">${esc(label)}<button title="Filter entfernen" onclick="setLevel('')">✕</button></span>`);
|
||
}
|
||
if (chips.length > 1) chips.push(`<span class="fchip clear-all"><button onclick="clearFilters()">alle entfernen</button></span>`);
|
||
box.innerHTML = chips.join('');
|
||
}
|
||
|
||
// ---------- Laden ----------
|
||
|
||
function sinceFor(range) {
|
||
const now = new Date();
|
||
switch (range) {
|
||
case '1h': return new Date(now - 3600e3);
|
||
case 'today': { const d = new Date(now); d.setHours(0, 0, 0, 0); return d; }
|
||
case '7d': return new Date(now - 7 * 86400e3);
|
||
case '30d': return new Date(now - 30 * 86400e3);
|
||
default: return null;
|
||
}
|
||
}
|
||
|
||
function buildParams(beforeId) {
|
||
const p = new URLSearchParams({limit: PAGE_SIZE});
|
||
const acc = document.getElementById('log-account').value;
|
||
if (acc) p.set('account_id', acc);
|
||
if (levelFilter) p.set('level', levelFilter);
|
||
const since = sinceFor(document.getElementById('log-range').value);
|
||
if (since) p.set('since', since.toISOString());
|
||
const tokens = tokenize(document.getElementById('log-search').value);
|
||
const free = [];
|
||
for (const t of tokens) {
|
||
if (t.key) p.set(t.key, t.value);
|
||
else free.push(t.value);
|
||
}
|
||
if (free.length) p.set('q', free.join(' '));
|
||
if (beforeId) p.set('before_id', beforeId);
|
||
renderActiveFilters(tokens);
|
||
syncUrl();
|
||
return p;
|
||
}
|
||
|
||
async function fetchLogs(beforeId = null) {
|
||
if (inflight) inflight.abort();
|
||
inflight = new AbortController();
|
||
const params = buildParams(beforeId);
|
||
const resp = await fetch(`/api/logs/?${params}`, {signal: inflight.signal});
|
||
const text = await resp.text();
|
||
let data;
|
||
try { data = JSON.parse(text); } catch { throw new Error(text || `HTTP ${resp.status}`); }
|
||
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
|
||
return data;
|
||
}
|
||
|
||
async function reload() {
|
||
const container = document.getElementById('log-container');
|
||
if (!logs.length) container.innerHTML = '<p aria-busy="true">Log wird geladen…</p>';
|
||
else container.style.opacity = '0.55';
|
||
try {
|
||
const data = await fetchLogs();
|
||
logs = data.logs;
|
||
hasMore = data.has_more;
|
||
nextBeforeId = data.next_before_id;
|
||
renderStats(data);
|
||
renderLogs();
|
||
} catch (e) {
|
||
if (e.name === 'AbortError') return;
|
||
container.innerHTML = `<p style="color:#b42318">Fehler beim Laden: ${esc(e.message)}</p>`;
|
||
} finally {
|
||
container.style.opacity = '';
|
||
}
|
||
}
|
||
|
||
async function loadMore() {
|
||
const btn = document.querySelector('#log-more button');
|
||
if (btn) btn.setAttribute('aria-busy', 'true');
|
||
try {
|
||
const data = await fetchLogs(nextBeforeId);
|
||
logs = logs.concat(data.logs);
|
||
hasMore = data.has_more;
|
||
nextBeforeId = data.next_before_id;
|
||
renderLogs();
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') alert('Fehler: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function onSearchInput() {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(reload, 350);
|
||
}
|
||
|
||
function setLevel(l) {
|
||
levelFilter = l;
|
||
document.querySelectorAll('#level-filter button').forEach(b => b.classList.toggle('active', b.dataset.l === l));
|
||
reload();
|
||
}
|
||
|
||
function toggleHelp() {
|
||
const h = document.getElementById('search-help');
|
||
h.hidden = !h.hidden;
|
||
}
|
||
|
||
// ---------- Darstellung ----------
|
||
|
||
function renderStats(data) {
|
||
const el = document.getElementById('log-stats');
|
||
if (data.total == null) return;
|
||
el.textContent = data.total_capped ? 'mehr als 1.000 Einträge' : `${data.total.toLocaleString('de-DE')} Einträge`;
|
||
}
|
||
|
||
function emailOf(from) {
|
||
const m = String(from || '').match(/<([^>]+)>/);
|
||
return m ? m[1] : String(from || '').trim();
|
||
}
|
||
|
||
function targetOf(log) {
|
||
// Bei Aktionen steht der Zielordner in details, sonst Quellordner
|
||
if (log.action_type === 'move' && log.details) return {text: '📁 ' + log.details.replace(/^INBOX[./]/, ''), filter: log.details};
|
||
if (log.action_type === 'pdf' && log.details) return {text: '📄 ' + log.details.replace(/^INBOX[./]/, ''), filter: log.details};
|
||
if (log.action_type === 'forward' && log.details) return {text: '↪ ' + log.details, filter: null};
|
||
if (log.action_type === 'delete') return {text: '🗑 Papierkorb', filter: null};
|
||
if (log.folder) return {text: log.folder, filter: log.folder};
|
||
return {text: '', filter: null};
|
||
}
|
||
|
||
function shortMessage(log) {
|
||
const m = log.message || '';
|
||
if (log.action_type && m.startsWith('Aktion ausgeführt')) {
|
||
return {move: 'Verschoben', pdf: 'Als PDF angehängt & abgelegt', forward: 'Weitergeleitet', delete: 'Gelöscht', mark_read: 'Als gelesen markiert'}[log.action_type] || m;
|
||
}
|
||
return m;
|
||
}
|
||
|
||
function jsArg(s) {
|
||
return esc(JSON.stringify(String(s)));
|
||
}
|
||
|
||
function renderLogs() {
|
||
const container = document.getElementById('log-container');
|
||
const more = document.getElementById('log-more');
|
||
if (!logs.length) {
|
||
const hasFilter = document.getElementById('log-search').value.trim() || levelFilter;
|
||
container.innerHTML = `<article><p class="muted">Keine Einträge${hasFilter ? ' für diese Suche' : ''} im gewählten Zeitraum.</p>
|
||
${document.getElementById('log-range').value !== 'all' ? '<button class="outline small" onclick="document.getElementById(\'log-range\').value=\'all\'; reload();">Gesamten Zeitraum durchsuchen</button>' : ''}</article>`;
|
||
more.innerHTML = '';
|
||
return;
|
||
}
|
||
const today = new Date().toDateString();
|
||
let html = `<div class="logs"><div class="l head"><span>Zeit</span><span></span><span>Regel</span><span>Ereignis / Mail</span><span>Ordner / Ziel</span></div>`;
|
||
for (const log of logs) {
|
||
const d = log.created_at ? parseUtc(log.created_at) : null;
|
||
const time = d ? d.toLocaleTimeString('de-DE') : '';
|
||
const day = d && d.toDateString() !== today ? d.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit', year: '2-digit'}) : '';
|
||
const target = targetOf(log);
|
||
const mail = (log.mail_subject || log.mail_from)
|
||
? `<div class="l-mail"><b>${esc(log.mail_subject || '(kein Betreff)')}</b>${log.mail_from ? ` · <a class="flink" onclick="event.stopPropagation(); addFilter('sender', ${jsArg(emailOf(log.mail_from))})" title="Nach diesem Absender filtern">${esc(log.mail_from)}</a>` : ''}</div>`
|
||
: '';
|
||
html += `
|
||
<div class="l lv-${esc(log.level)}${log.retried_at ? ' retried' : ''}" onclick="toggleDetail(this, ${log.id})">
|
||
<span class="l-time">${day ? `<span class="l-day">${day}</span>` : ''}${time}</span>
|
||
<span title="${esc(log.level)}">${LEVEL_ICON[log.level] || ''}</span>
|
||
<span class="l-rule">${log.rule_name ? `<a class="flink" onclick="event.stopPropagation(); addFilter('rule', ${jsArg(log.rule_name)})" title="Nach dieser Regel filtern">${esc(log.rule_name)}</a>` : '<span class="muted">—</span>'}</span>
|
||
<span class="l-main"><div class="l-msg">${log.retried_at ? `<span class="retried-tag" title="Mit „Fehlgeschlagene neu einlesen“ erneut angestoßen. Scheitert es wieder, erscheint ein neuer Fehler-Eintrag.">↻ erneut versucht ${esc(fmtShort(log.retried_at))}</span> ` : ''}${esc(shortMessage(log))}</div>${mail}</span>
|
||
<span class="l-folder">${target.filter ? `<a class="flink" onclick="event.stopPropagation(); addFilter('folder', ${jsArg(target.filter)})" title="Nach diesem Ordner filtern">${esc(target.text)}</a>` : esc(target.text)}</span>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
more.innerHTML = hasMore
|
||
? `<button class="outline secondary" onclick="loadMore()">Ältere laden</button> <span class="muted small-text">${logs.length} angezeigt</span>`
|
||
: `<span class="muted small-text">${logs.length} angezeigt · Ende des Zeitraums</span>`;
|
||
}
|
||
|
||
function toggleDetail(row, id) {
|
||
const open = row.querySelector('.l-detail');
|
||
if (open) { open.remove(); row.classList.remove('open'); return; }
|
||
const log = logs.find(l => l.id === id);
|
||
if (!log) return;
|
||
const rows = [
|
||
['Zeit', log.created_at ? parseUtc(log.created_at).toLocaleString('de-DE') : ''],
|
||
['Konto', log.account_name],
|
||
['Nachricht', log.message],
|
||
['Regel', log.rule_name],
|
||
['Von', log.mail_from],
|
||
['Betreff', log.mail_subject],
|
||
['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';
|
||
div.onclick = e => e.stopPropagation();
|
||
div.innerHTML = `<dl>${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>`;
|
||
row.appendChild(div);
|
||
row.classList.add('open');
|
||
}
|
||
|
||
// ---------- Aktionen ----------
|
||
|
||
async function readJson(resp) {
|
||
const text = await resp.text();
|
||
try { return JSON.parse(text); } catch { return {detail: text}; }
|
||
}
|
||
|
||
async function clearLogs() {
|
||
document.querySelector('.head-menu').open = false;
|
||
const acc = document.getElementById('log-account');
|
||
const scope = acc.value ? `für „${acc.options[acc.selectedIndex].text}“` : 'für alle Konten';
|
||
if (!confirm(`Log ${scope} wirklich leeren?`)) return;
|
||
const resp = await fetch(`/api/logs/${acc.value ? `?account_id=${acc.value}` : ''}`, {method: 'DELETE'});
|
||
const d = await readJson(resp);
|
||
if (!resp.ok) { alert('Fehler beim Leeren: ' + (d.detail || resp.status)); return; }
|
||
logs = [];
|
||
reload();
|
||
}
|
||
|
||
async function retryFailed() {
|
||
document.querySelector('.head-menu').open = false;
|
||
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.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) {
|
||
if (n == null) return '?';
|
||
if (n >= 1e9) return (n / 1e9).toLocaleString('de-DE', {maximumFractionDigits: 2}) + ' GB';
|
||
return Math.round(n / 1e6).toLocaleString('de-DE') + ' MB';
|
||
}
|
||
|
||
async function loadDbStatus() {
|
||
const el = document.getElementById('db-status');
|
||
try {
|
||
const resp = await fetch('/api/logs/maintenance');
|
||
const s = await readJson(resp);
|
||
if (!resp.ok) throw new Error(s.detail || resp.status);
|
||
const last = s.last_run && s.last_run.started_at
|
||
? `Letzte Wartung: ${parseUtc(s.last_run.started_at).toLocaleString('de-DE', {dateStyle: 'short', timeStyle: 'short'})}`
|
||
: 'Wartung läuft täglich automatisch';
|
||
el.innerHTML = `Datenbank: <b>${fmtBytes(s.file_bytes)}</b> (davon ${fmtBytes(s.free_bytes)} ungenutzt)<br>
|
||
Log: ${s.log_rows.toLocaleString('de-DE')} Einträge · Aufbewahrung ${s.retention_days} Tage, max. ${s.max_entries.toLocaleString('de-DE')}<br>
|
||
${esc(last)}`;
|
||
} catch (e) {
|
||
el.textContent = 'Status nicht verfügbar: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function runMaintenance() {
|
||
document.querySelector('.head-menu').open = false;
|
||
if (!confirm('Datenbank jetzt aufräumen und verkleinern?\n\nAlte Log-Einträge werden nach der Aufbewahrungsregel gelöscht und die Datei wird neu geschrieben. Bei großen Datenbanken kann das einige Minuten dauern; die Mail-Verarbeitung wartet solange.')) return;
|
||
const stats = document.getElementById('log-stats');
|
||
const prev = stats.textContent;
|
||
stats.innerHTML = '<span aria-busy="true">Datenbank wird aufgeräumt…</span>';
|
||
try {
|
||
const resp = await fetch('/api/logs/maintenance?vacuum=true', {method: 'POST'});
|
||
const d = await readJson(resp);
|
||
if (!resp.ok) throw new Error(d.detail || resp.status);
|
||
const r = d.result;
|
||
if (r.skipped) { alert(r.skipped); return; }
|
||
alert(`Fertig in ${r.duration_s} s.\n\n` +
|
||
`Größe: ${fmtBytes(r.size_before)} → ${fmtBytes(r.size_after)}\n` +
|
||
`Gelöschte Log-Einträge: ${(r.logs_deleted_age + r.logs_deleted_cap).toLocaleString('de-DE')}\n` +
|
||
`Verwaiste Marker entfernt: ${r.processed_orphans_deleted.toLocaleString('de-DE')}\n` +
|
||
`Verkleinern: ${r.vacuum}`);
|
||
reload();
|
||
} catch (e) {
|
||
alert('Wartung fehlgeschlagen: ' + e.message);
|
||
} finally {
|
||
if (stats.querySelector('[aria-busy]')) stats.textContent = prev;
|
||
}
|
||
}
|
||
|
||
function toggleAutoRefresh() {
|
||
clearInterval(refreshTimer);
|
||
refreshTimer = null;
|
||
if (document.getElementById('auto-refresh').checked) {
|
||
// Nur neu laden, wenn niemand gerade eine Zeile aufgeklappt hat
|
||
refreshTimer = setInterval(() => { if (!document.querySelector('.l.open')) reload(); }, 10000);
|
||
reload();
|
||
}
|
||
}
|
||
|
||
// Suche, Level, Zeitraum und Konto in der URL halten: Reload behält die
|
||
// Filter, und eine Suche lässt sich als Lesezeichen speichern.
|
||
function syncUrl() {
|
||
const u = new URLSearchParams();
|
||
const q = document.getElementById('log-search').value.trim();
|
||
if (q) u.set('q', q);
|
||
if (levelFilter) u.set('level', levelFilter);
|
||
const range = document.getElementById('log-range').value;
|
||
if (range !== '7d') u.set('range', range);
|
||
const acc = document.getElementById('log-account').value;
|
||
if (acc) u.set('account', acc);
|
||
history.replaceState(null, '', u.toString() ? `?${u}` : location.pathname);
|
||
}
|
||
|
||
(function init() {
|
||
const u = new URLSearchParams(location.search);
|
||
if (u.get('q')) document.getElementById('log-search').value = u.get('q');
|
||
if (u.get('range')) document.getElementById('log-range').value = u.get('range');
|
||
if (u.get('account')) document.getElementById('log-account').value = u.get('account');
|
||
levelFilter = u.get('level') || '';
|
||
document.querySelectorAll('#level-filter button').forEach(b => b.classList.toggle('active', b.dataset.l === levelFilter));
|
||
reload();
|
||
})();
|
||
</script>
|
||
{% endblock %}
|