Files
imap-mail-filter-service/app/templates/logs.html
T
duffyduckandClaude Opus 5.5 fbb2fe95c8 move mails without Message-ID; log the reason for failed actions
A spam mail matched stefanhacker@gmx.de but "Aktion fehlgeschlagen"
every time: it has no Message-ID header, and the safe move refused to
proceed because it verifies the copy by Message-ID. So every mail
without that header stayed in INBOX forever, and the log didn't say why.

Verification now falls back when there is no Message-ID:
- COPYUID from the COPY response when the server supports UIDPLUS
- otherwise count mails in the target with the same exact size and
  sender before and after COPY; exactly one more means it arrived
The source is still only flagged/expunged after verification, and
move_mail now refuses to run without source_folder (the verification
selects the target, so STORE could otherwise hit the wrong folder).
Checked against a simulated IMAP server for success, rejected COPY,
copy not arriving, and missing source folder.

IMAPClient.last_error carries the reason (rejected COPY with server
text, copy not found incl. how it was checked, missing target folder,
no SMTP config, ...); the scheduler appends it to the log message.
"Fehlgeschlagene neu einlesen" now also retries failed actions, not
only fetch errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:02:30 +02:00

529 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 &amp; 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; }
.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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 === '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', 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)}" 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">${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],
].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 Mails, bei denen Abrufen oder eine Aktion (z.B. Verschieben) fehlgeschlagen ist, für den nächsten Poll neu vormerken?')) 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.`);
}
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 %}