mail: bring eventlog up to speed

This commit is contained in:
Girish Ramakrishnan
2026-02-17 15:58:37 +01:00
parent 66f65093fc
commit f08b3eb006
3 changed files with 133 additions and 28 deletions
+2 -2
View File
@@ -292,10 +292,10 @@ function create() {
if (result.status !== 202) return [result];
return [null];
},
async eventlog(types, search, page, perPage) {
async eventlog(types, search, page, perPage, from, to) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/eventlog`, { page, types, per_page: perPage, search, access_token: accessToken });
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/eventlog`, { page, types, per_page: perPage, search, from, to, access_token: accessToken });
} catch (e) {
return [e];
}
+130 -25
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, reactive, onMounted, watch, useTemplateRef, nextTick } from 'vue';
import { Button, TextInput, MultiSelect } from '@cloudron/pankow';
import { ref, reactive, computed, onMounted, watch, useTemplateRef, nextTick } from 'vue';
import { Button, TextInput, MultiSelect, Popover, FormGroup, DateTimeInput } from '@cloudron/pankow';
import { useDebouncedRef, prettyEmailAddresses, prettyLongDate } from '@cloudron/pankow/utils';
import MailModel from '../models/MailModel.js';
@@ -21,18 +21,98 @@ const availableTypes = [
const refreshBusy = ref(false);
const eventlogs = ref([]);
const search = useDebouncedRef('');
const page = ref(1);
const perPage = ref(10);
const perPage = ref(100);
const eventlogContainer = useTemplateRef('eventlogContainer');
// eslint-disable-next-line prefer-const
let types = reactive([]);
const filterFrom = ref('');
const filterTo = ref('');
const dateFilterPopover = useTemplateRef('dateFilterPopover');
const dateFilterButton = useTemplateRef('dateFilterButton');
const highlight = useDebouncedRef('', 300);
const currentMatchPosition = ref(-1);
const searching = ref(false);
const SEARCH_LOOKAHEAD_PAGES = 5;
function isMatch(eventlog, term) {
if (!term) return false;
const t = term.toLowerCase();
const fields = [
prettyEmailAddresses(eventlog.mailFrom),
prettyEmailAddresses(eventlog.rcptTo),
eventlog.mailbox,
eventlog.type,
eventlog.message,
eventlog.reason,
JSON.stringify(eventlog),
];
return fields.some(f => f && String(f).toLowerCase().includes(t));
}
const matchIndices = computed(() => {
if (!highlight.value) return [];
return eventlogs.value.reduce((acc, e, i) => {
if (isMatch(e, highlight.value)) acc.push(i);
return acc;
}, []);
});
function scrollToIndex(idx) {
const el = eventlogContainer.value?.querySelector(`[data-index="${idx}"]`);
if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
}
function goToPrevMatch() {
if (currentMatchPosition.value > 0) {
currentMatchPosition.value--;
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
}
}
async function goToNextMatch() {
if (!highlight.value || searching.value) return;
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
currentMatchPosition.value++;
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
return;
}
searching.value = true;
let endOfLog = false;
for (let i = 0; i < SEARCH_LOOKAHEAD_PAGES; i++) {
const prevLength = eventlogs.value.length;
await fetchMore();
if (eventlogs.value.length === prevLength) { endOfLog = true; break; }
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
currentMatchPosition.value++;
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
searching.value = false;
return;
}
}
searching.value = false;
if (endOfLog) window.pankow.notify({ text: `No more matches for "${highlight.value}".`, timeout: 3000 });
else window.pankow.notify({ text: `No match found for "${highlight.value}" in ${eventlogs.value.length} entries. Click next to keep searching.`, timeout: 3000 });
}
function buildFromTo() {
const from = filterFrom.value ? new Date(filterFrom.value + 'T00:00:00').toISOString() : undefined;
const to = filterTo.value ? new Date(filterTo.value + 'T23:59:59.999').toISOString() : undefined;
return { from, to };
}
async function onRefresh() {
highlight.value = '';
refreshBusy.value = true;
page.value = 1;
const [error, result] = await mailModel.eventlog(types.join(','), search.value, page.value, perPage.value);
const { from, to } = buildFromTo();
const [error, result] = await mailModel.eventlog(types.join(','), '', page.value, perPage.value, from, to);
if (error) return console.error(error);
eventlogs.value = result;
@@ -49,7 +129,8 @@ async function onRefresh() {
async function fetchMore() {
page.value++;
const [error, result] = await mailModel.eventlog(types.join(','), search.value, page.value, perPage.value);
const { from, to } = buildFromTo();
const [error, result] = await mailModel.eventlog(types.join(','), '', page.value, perPage.value, from, to);
if (error) return console.error(error);
eventlogs.value = eventlogs.value.concat(result);
@@ -59,9 +140,24 @@ async function onScroll(event) {
if (event.target.scrollTop + event.target.clientHeight >= event.target.scrollHeight) await fetchMore();
}
function onOpenDateFilter(event) {
dateFilterPopover.value.open(event, dateFilterButton.value.$el || dateFilterButton.value);
}
watch(perPage, onRefresh);
watch(types, onRefresh);
watch(search, onRefresh);
watch(filterFrom, onRefresh);
watch(filterTo, onRefresh);
watch(highlight, async () => {
if (matchIndices.value.length > 0) {
currentMatchPosition.value = 0;
await nextTick();
scrollToIndex(matchIndices.value[0]);
} else {
currentMatchPosition.value = -1;
if (highlight.value) goToNextMatch();
}
});
onMounted(async () => {
await onRefresh();
@@ -81,12 +177,27 @@ onMounted(async () => {
<h2 class="section-header">
{{ $t('emails.eventlog.title') }}
<div>
<TextInput :placeholder="$t('main.searchPlaceholder')" style="flex-grow: 1;" v-model="search"/>
<TextInput placeholder="Highlight..." v-model="highlight" @keydown.enter="goToNextMatch()"/>
<Button tool plain :disabled="!highlight || currentMatchPosition <= 0 || searching" @click="goToPrevMatch()" icon="fa-solid fa-chevron-up" />
<Button tool plain :disabled="!highlight || searching" :loading="searching" @click="goToNextMatch()" icon="fa-solid fa-chevron-down" />
<Button tool secondary ref="dateFilterButton" @click="onOpenDateFilter($event)" :icon="(filterFrom || filterTo) ? 'fa-solid fa-calendar-check' : 'fa-solid fa-calendar'" />
<MultiSelect v-model="types" :options="availableTypes" option-key="id" option-label="name" :selected-label="types.length ? $t('main.multiselect.selected', { n: types.length }) : $t('emails.typeFilterHeader')"/>
<Button tool secondary @click="onRefresh()" :loading="refreshBusy" icon="fa-solid fa-sync-alt" />
<Button tool secondary href="/logs.html?id=mail" target="_blank">{{ $t('main.action.logs') }}</Button>
</div>
</h2>
<Popover ref="dateFilterPopover" width="300px">
<div style="padding: 15px; display: flex; flex-direction: column; gap: 10px;">
<FormGroup>
<label>From</label>
<DateTimeInput date-only v-model="filterFrom" :max="filterTo || undefined" />
</FormGroup>
<FormGroup>
<label>To</label>
<DateTimeInput date-only v-model="filterTo" :min="filterFrom || undefined" />
</FormGroup>
</div>
</Popover>
<div class="section-body" ref="eventlogContainer" style="margin-top: 16px; overflow: auto; padding-top: 0" @scroll="onScroll">
<table class="eventlog-table">
<thead>
@@ -99,8 +210,8 @@ onMounted(async () => {
</tr>
</thead>
<tbody>
<template v-for="eventlog in eventlogs" :key="eventlog._id">
<tr @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen }" >
<template v-for="(eventlog, index) in eventlogs" :key="eventlog._id">
<tr :data-index="index" @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen, 'eventlog-match': highlight && isMatch(eventlog, highlight), 'eventlog-match-current': matchIndices[currentMatchPosition] === index }" >
<td>
<i class="fas fa-arrow-circle-left" v-if="eventlog.type === 'sent'" v-tooltip="$t('emails.eventlog.type.outgoing')"></i>
<i class="fas fa-history" v-if="eventlog.type === 'deferred'" v-tooltip="$t('emails.eventlog.type.deferred')"></i>
@@ -163,7 +274,7 @@ onMounted(async () => {
background-color: var(--pankow-body-background-color);
top: 0;
position: sticky;
z-index: 1; /* avoids see-through table headers if items in the table have opacity set */
z-index: 1;
}
.eventlog-table th {
@@ -184,25 +295,12 @@ onMounted(async () => {
padding: 6px;
}
.eventlog-filter {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin: 20px 0;
}
.eventlog-details {
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
cursor: auto;
position: relative;
}
.eventlog-source {
padding-left: 10px;
padding-bottom: 10px;
cursor: copy;
}
.eventlog-details pre {
white-space: pre-wrap;
color: var(--pankow-text-color);
@@ -213,4 +311,11 @@ onMounted(async () => {
border-radius: var(--pankow-border-radius);
}
</style>
.eventlog-table tbody tr.eventlog-match {
background-color: rgba(255, 193, 7, 0.15);
}
.eventlog-table tbody tr.eventlog-match-current {
background-color: rgba(255, 193, 7, 0.35);
}
</style>