@@ -1,305 +1,476 @@
{% extends "base.html" %}
{% block title %}Verarbeitungsl og — IMAP Mail Filter{% endblock %}
{% block title %}L og — 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)
< 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 >
< 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 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 = "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 = "m arg in-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 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,w arn ing " onclick = "setLevel('error,warning')" > ❌ Fehler < / button >
< button data-l = "info" onclick = "setLevel('info')" > ℹ Info< / button >
< / 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 >
< 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 >
. log-table {
width : 100 % ;
font-size : 0.9 rem ;
}
. log-table th , . log-table td {
padding : 0.4 rem 0.6 rem ;
vertical-align : top ;
}
. log-row { border-left : 3 px 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 ; }
. muted { color : var ( - - pico - muted - color ) ; }
. small-text { font-size : 0.85 rem ; }
. page-head { display : flex ; align-items : center ; gap : 0.6 rem ; flex-wrap : wrap ; margin-bottom : 0.75 rem ; }
. page-head h1 { margin : 0 0.4 rem 0 0 ; }
. head-select { width : auto ; margin : 0 ; padding-top : 0.3 rem ; padding-bottom : 0.3 rem ; font-size : 0.9 rem ; }
. page-head # log-stats { flex : 1 ; }
. auto-refresh { margin : 0 ; font-size : 0.85 rem ; white-space : nowrap ; }
. head-menu { margin : 0 ; }
. head-menu summary { padding : 0.2 rem 0.7 rem ; height : auto ; font-size : 1.1 rem ; line-height : 1.2 ; }
. head-menu summary :: after { display : none ; }
. head-menu ul { min-width : 230 px ; font-size : 0.9 rem ; }
. log-level {
display : inline-block ;
padding : 0.1 rem 0.4 rem ;
border-radius : 3 px ;
font-size : 0.75 rem ;
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 ; }
. toolbar { display : flex ; gap : 0.5 rem ; align-items : center ; flex-wrap : wrap ; margin-bottom : 0.4 rem ; }
. search-wrap { position : relative ; flex : 1 1 360 px ; }
. search-wrap input { margin : 0 ; padding-top : 0.35 rem ; padding-bottom : 0.35 rem ; padding-right : 2.4 rem ; font-size : 0.9 rem ; }
. search-wrap . help-btn { position : absolute ; right : 0.35 rem ; top : 50 % ; transform : translateY ( -50 % ) ; margin : 0 ; padding : 0 0.5 rem ;
font-size : 0.8 rem ; line-height : 1.6 ; border-radius : 999 px ;
--pico-background-color : #eef0f3 ; --pico-border-color : transparent ; --pico-color : #555 ; }
. seg { display : inline-flex ; border : 1 px 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.3 rem 0.7 rem ; font-size : 0.8 rem ; box-shadow : none ; }
. toolbar . seg button + button { border-left : 1 px 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 ) ; }
. log-detail { font-size : 0.8 rem ; opacity : 0.7 ; }
. log-mail-info { font-size : 0.8 rem ; color : var ( - - pico - muted - color ) ; }
. search-help { background : #fff ; border : 1 px solid var ( - - app - border , #e3e6ea ) ; border-radius : var ( - - pico - border - radius ) ;
padding : 0.6 rem 0.8 rem ; font-size : 0.83 rem ; margin-bottom : 0.5 rem ; }
. help-grid { display : grid ; grid-template-columns : max-content 1 fr ; gap : 0.15 rem 1 rem ; margin : 0.4 rem 0 ; }
. help-grid code { font-size : 0.8 rem ; padding : 0 0.3 rem ; }
. active-filters { display : flex ; flex-wrap : wrap ; gap : 0.3 rem ; margin-bottom : 0.5 rem ; min-height : 0 ; }
. fchip { display : inline-flex ; align-items : center ; gap : 0.25 rem ; font-size : 0.78 rem ; padding : 0.05 rem 0.2 rem 0.05 rem 0.5 rem ;
border-radius : 999 px ; background : #e8eefc ; color : #274b9f ; border : 1 px solid #cdd9f5 ; }
. fchip button { --pico-background-color : transparent ; --pico-border-color : transparent ; --pico-color : #274b9f ;
margin : 0 ; padding : 0 0.3 rem ; font-size : 0.8 rem ; line-height : 1.3 ; box-shadow : none ; }
. fchip . clear-all { background : transparent ; border-color : transparent ; }
. logs { background : #fff ; border : 1 px solid var ( - - app - border , #e3e6ea ) ; border-radius : var ( - - pico - border - radius ) ; overflow : hidden ; font-size : 0.84 rem ; }
. logs . l { display : grid ; grid-template-columns : 98 px 22 px minmax ( 110 px , 170 px ) minmax ( 0 , 1 fr ) minmax ( 90 px , 190 px ) ;
gap : 0.6 rem ; align-items : baseline ; padding : 0.3 rem 0.75 rem ; border-bottom : 1 px solid #eef0f3 ; cursor : pointer ; }
. logs . l : hover { background : #f8f9fb ; }
. logs . l . open { background : #f4f7fd ; }
. logs . l . head { background : #f6f7f9 ; cursor : default ; font-size : 0.72 rem ; font-weight : 600 ; text-transform : uppercase ; letter-spacing : 0.03 em ; color : var ( - - pico - muted - color ) ; }
. logs . l . lv-error { box-shadow : inset 3 px 0 0 #dc3545 ; }
. logs . l . lv-warning { box-shadow : inset 3 px 0 0 #ffc107 ; }
. logs . l . lv-success { box-shadow : inset 3 px 0 0 #28a745 ; }
. l-time { color : var ( - - pico - muted - color ) ; font-variant-numeric : tabular-nums ; white-space : nowrap ; font-size : 0.78 rem ; }
. l-day { display : block ; font-size : 0.68 rem ; 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.78 rem ; 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 : 1 px 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.78 rem ; background : #fff ; border : 1 px solid #e6e9ed ; border-radius : 4 px ;
padding : 0.4 rem 0.6 rem ; margin : 0.2 rem 0 0.2 rem ; cursor : text ; white-space : pre-wrap ; word-break : break-word ; }
. l-detail dl { display : grid ; grid-template-columns : max-content 1 fr ; gap : 0.1 rem 0.8 rem ; margin : 0 ; }
. l-detail dt { color : var ( - - pico - muted - color ) ; }
. l-detail dd { margin : 0 ; }
# log-more { text-align : center ; margin-top : 0.6 rem ; }
# log-more button { padding : 0.3 rem 1 rem ; font-size : 0.85 rem ; }
@ media ( max-width : 800px ) {
. logs . l { grid-template-columns : 80 px 22 px minmax ( 0 , 1 fr ) ; }
. logs . l . l-rule , . logs . l . l-folder , . logs . l . head { display : none ; }
}
< / style >
< script >
let currentOffset = 0 ;
const PAGE _SIZE = 50 ;
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 ;
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' ) ;
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' ) ;
}
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 ( ) ;
// ---------- Suchsyntax ----------
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 ) } ` ;
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 ;
}
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 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 renderStats ( total ) {
document . getElementById ( 'log-stats' ) . innerHTML = ` <small> ${ total } Einträge gesamt</small> ` ;
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 ;
}
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.
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 ( ) ;
try { return { ok : resp . ok , status : resp . status , data : JSON . parse ( text ) } ; }
catch { return { ok : resp . ok , status : resp . status , data : null , 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 ( ) {
if ( ! confirm ( 'Log wirklich leer en? ') ) 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 ) ;
}
document . querySelector ( '.head-m enu ') . 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 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 ) ;
}
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 ) {
refreshTimer = setInterval ( ( ) => loadLogs ( currentOffset ) , 5000 ) ;
} else {
clearInterval ( refreshTimer ) ;
refreshTimer = null ;
// Nur neu laden, wenn niemand gerade eine Zeile aufgeklappt hat
refreshTimer = setInterval ( ( ) => { if ( ! document . querySelector ( '.l.open' ) ) reload ( ) ; } , 10000 ) ;
reload ( ) ;
}
}
function escapeHtml ( text ) {
const div = document . createElement ( 'div' ) ;
div . textContent = text ;
return div . innerHTML ;
// 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 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 ( ) ;
( 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 %}