Files
imap-mail-filter-service/app/templates/logs.html
T
duffyduckandClaude Opus 5.5 9bd2507cb1 fast, readable log: stop routine noise, keyset paging, token search
The log had 6.45M rows, of which 434 were actual actions: every mail x
every non-matching rule wrote "Keine Übereinstimmung" (5.3M rows, ~1 GB),
plus "keine neuen Mails" per rule every 30s and a "Poll gestartet" row.
Free-text searches took 50-70s.

- Routine entries are only written with LOG_VERBOSE=true; the poll
  summary is logged only when there were new mails, actions or errors.
  "Why didn't my rule match" is now answered by the rule tester.
- The scheduler commits every 25 evaluated mails and after each rule,
  since the dropped log calls used to be the implicit commit points.
- Migration rebuilds filter_logs without the routine rows (live:
  6.45M -> ~38k) via copy instead of DELETE.
- /api/logs: keyset pagination (before_id) instead of OFFSET, count
  capped at 1000, since/until range, multi-level filter, per-field
  filters; ordner: also matches the move target stored in details.
- New log page: one search field with shortcuts (von:, betreff:,
  regel:, ordner:, text:), live search with debounce and abort, active
  filters as removable chips, click on rule/sender/folder in a row to
  filter, time range (default 7 days), level quick filter, expandable
  row details, "Ältere laden", filters kept in the URL.
- Disable Pico's colour transition on segmented buttons (they briefly
  flashed in the primary colour on load).

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

477 lines
22 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">
<summary>⋯</summary>
<ul dir="rtl">
<li><a href="#" onclick="retryFailed(); return false;" title="Löscht den 'verarbeitet'-Marker für Mails mit Abruf-Fehler, damit sie beim nächsten Poll neu versucht werden">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:230px; font-size:0.9rem; }
.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 mit "Fehler beim Abrufen" 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 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 %}