eventlog: implement contextual highlight
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, reactive, onMounted, onUnmounted, watch, useTemplateRef } from 'vue';
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue';
|
||||
import { Button, TextInput, MultiSelect, Popover, FormGroup, DateTimeInput } from '@cloudron/pankow';
|
||||
import { useDebouncedRef, prettyLongDate, prettyShortDate } from '@cloudron/pankow/utils';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
@@ -100,12 +100,73 @@ const availableActions = [
|
||||
const refreshBusy = ref(false);
|
||||
const apps = ref([]);
|
||||
const eventlogs = ref([]);
|
||||
const search = useDebouncedRef('');
|
||||
const highlight = useDebouncedRef('', 300);
|
||||
const page = ref(1);
|
||||
const perPage = ref(40);
|
||||
const perPage = ref(100);
|
||||
const eventlogContainer = useTemplateRef('eventlogContainer');
|
||||
// eslint-disable-next-line prefer-const
|
||||
let actions = reactive([]);
|
||||
const actions = reactive([]);
|
||||
|
||||
const currentMatchPosition = ref(-1);
|
||||
const searching = ref(false);
|
||||
const SEARCH_LOOKAHEAD_PAGES = 5; // 5 pages x 100 per page = 500 events per "next" click
|
||||
|
||||
function isMatch(eventlog, term) {
|
||||
if (!term) return false;
|
||||
const t = term.toLowerCase();
|
||||
if (eventlog.source.toLowerCase().includes(t)) return true;
|
||||
if (eventlog.details.replace(/<[^>]+>/g, '').toLowerCase().includes(t)) return true;
|
||||
if (JSON.stringify(eventlog.raw.data).toLowerCase().includes(t)) return true;
|
||||
if (eventlog.raw.action.toLowerCase().includes(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
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 (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 });
|
||||
}
|
||||
|
||||
const filterFrom = ref('');
|
||||
const filterTo = ref('');
|
||||
@@ -117,10 +178,11 @@ function onOpenDateFilter(event) {
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
highlight.value = '';
|
||||
refreshBusy.value = true;
|
||||
page.value = 1;
|
||||
|
||||
const filter = { actions: actions.join(','), search: search.value };
|
||||
const filter = { actions: actions.join(',') };
|
||||
if (filterFrom.value) filter.from = new Date(filterFrom.value + 'T00:00:00').toISOString();
|
||||
if (filterTo.value) filter.to = new Date(filterTo.value + 'T23:59:59.999').toISOString();
|
||||
|
||||
@@ -141,7 +203,7 @@ async function onRefresh() {
|
||||
|
||||
async function fetchMore() {
|
||||
page.value++;
|
||||
const filter = { actions: actions.join(','), search: search.value };
|
||||
const filter = { actions: actions.join(',') };
|
||||
if (filterFrom.value) filter.from = new Date(filterFrom.value + 'T00:00:00').toISOString();
|
||||
if (filterTo.value) filter.to = new Date(filterTo.value + 'T23:59:59.999').toISOString();
|
||||
|
||||
@@ -163,13 +225,22 @@ async function onScroll(event) {
|
||||
}
|
||||
|
||||
watch(actions, 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();
|
||||
}
|
||||
});
|
||||
|
||||
async function onHashChange() {
|
||||
const params = new URLSearchParams(window.location.hash.slice(window.location.hash.indexOf('?')));
|
||||
search.value = params.get('search') || '';
|
||||
highlight.value = params.get('search') || '';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -179,12 +250,10 @@ onMounted(async () => {
|
||||
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
onHashChange();
|
||||
if (!search.value) {
|
||||
onRefresh();
|
||||
onRefresh();
|
||||
|
||||
while (eventlogContainer.value.scrollHeight <= eventlogContainer.value.offsetHeight) {
|
||||
await fetchMore();
|
||||
}
|
||||
while (eventlogContainer.value.scrollHeight <= eventlogContainer.value.offsetHeight) {
|
||||
await fetchMore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -201,7 +270,9 @@ onUnmounted(() => {
|
||||
<h1 class="section-header">
|
||||
{{ $t('eventlog.title') }}
|
||||
<div>
|
||||
<TextInput :placeholder="$t('main.searchPlaceholder')" style="flex-grow: 1;" v-model="search"/>
|
||||
<TextInput placeholder="Highlight..." style="flex-grow: 1;" 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 :search-threshold="10" v-model="actions" :options="availableActions" option-label="label" option-key="id" :selected-label="actions.length ? $t('main.multiselect.selected', { n: actions.length }) : $t('eventlog.filterAllEvents')"/>
|
||||
<Button tool secondary @click="onRefresh()" :loading="refreshBusy" icon="fa-solid fa-sync-alt" />
|
||||
@@ -220,7 +291,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</Popover>
|
||||
<div class="section-body" ref="eventlogContainer" style="overflow: auto; margin-top: 10px; padding-top: 0px" @scroll="onScroll">
|
||||
<div class="eventlog-item" v-for="eventlog in eventlogs" :key="eventlog.id" :class="{ 'active': eventlog.isOpen }" @click="eventlog.isOpen = !eventlog.isOpen">
|
||||
<div class="eventlog-item" v-for="(eventlog, index) in eventlogs" :key="eventlog.id" :data-index="index" :class="{ 'active': eventlog.isOpen, 'eventlog-match': highlight && isMatch(eventlog, highlight), 'eventlog-match-current': matchIndices[currentMatchPosition] === index }" @click="eventlog.isOpen = !eventlog.isOpen">
|
||||
<div class="eventlog-summary">
|
||||
<div style="width: 160px; flex-shrink: 0;" class="pankow-no-mobile">{{ prettyLongDate(eventlog.raw.creationTime) }}</div>
|
||||
<div style="width: 80px; flex-shrink: 0;" class="pankow-no-desktop">{{ prettyShortDate(eventlog.raw.creationTime) }}</div>
|
||||
@@ -271,4 +342,12 @@ onUnmounted(() => {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.eventlog-item.eventlog-match {
|
||||
background-color: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
|
||||
.eventlog-item.eventlog-match-current {
|
||||
background-color: rgba(255, 193, 7, 0.35);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user