eventlog: make a component and use it in app and system
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, reactive, computed, onMounted, 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';
|
||||
import { eventlogDetails, eventlogSource } from '../utils.js';
|
||||
|
||||
const props = defineProps({
|
||||
fetchPage: { type: Function, required: true },
|
||||
availableActions: { type: Array, default: () => [] },
|
||||
app: { type: Object, default: null },
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
const apps = ref([]);
|
||||
const eventlogs = ref([]);
|
||||
const refreshBusy = ref(false);
|
||||
const page = ref(1);
|
||||
const perPage = ref(100);
|
||||
const eventlogContainer = useTemplateRef('eventlogContainer');
|
||||
const actions = reactive([]);
|
||||
|
||||
const highlight = useDebouncedRef('', 300);
|
||||
const currentMatchPosition = ref(-1);
|
||||
const searching = ref(false);
|
||||
const SEARCH_LOOKAHEAD_PAGES = 5;
|
||||
|
||||
const filterFrom = ref('');
|
||||
const filterTo = ref('');
|
||||
const dateFilterPopover = useTemplateRef('dateFilterPopover');
|
||||
const dateFilterButton = useTemplateRef('dateFilterButton');
|
||||
|
||||
function getApp(id) {
|
||||
return apps.value.find(a => a.id === id);
|
||||
}
|
||||
|
||||
function processEvent(e) {
|
||||
const app = props.app || (e.data?.appId ? getApp(e.data.appId) : null);
|
||||
return {
|
||||
id: Symbol(),
|
||||
raw: e,
|
||||
details: eventlogDetails(e, app),
|
||||
source: eventlogSource(e, app),
|
||||
};
|
||||
}
|
||||
|
||||
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 (!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 buildFilter() {
|
||||
const filter = {};
|
||||
if (actions.length) 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();
|
||||
return filter;
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
highlight.value = '';
|
||||
refreshBusy.value = true;
|
||||
page.value = 1;
|
||||
|
||||
const [error, result] = await props.fetchPage(buildFilter(), page.value, perPage.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = result.map(processEvent);
|
||||
refreshBusy.value = false;
|
||||
}
|
||||
|
||||
async function fetchMore() {
|
||||
page.value++;
|
||||
|
||||
const [error, result] = await props.fetchPage(buildFilter(), page.value, perPage.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = eventlogs.value.concat(result.map(processEvent));
|
||||
}
|
||||
|
||||
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(actions, 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 () => {
|
||||
if (!props.app) {
|
||||
const [error, result] = await appsModel.list();
|
||||
if (error) console.error(error);
|
||||
else apps.value = result;
|
||||
}
|
||||
|
||||
onRefresh();
|
||||
|
||||
while (eventlogContainer.value.scrollHeight <= eventlogContainer.value.offsetHeight) {
|
||||
await fetchMore();
|
||||
}
|
||||
});
|
||||
|
||||
function setHighlight(value) { highlight.value = value; }
|
||||
|
||||
defineExpose({ refresh: onRefresh, setHighlight });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="overflow: hidden; display: flex; flex-direction: column; height: 100%;">
|
||||
<div v-if="showToolbar" style="display: flex; align-items: center; gap: 5px; flex-wrap: wrap; padding-bottom: 10px; justify-content: flex-end;">
|
||||
<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-if="availableActions.length" :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" />
|
||||
</div>
|
||||
<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 ref="eventlogContainer" style="overflow: auto; flex: 1;" @scroll="onScroll">
|
||||
<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>
|
||||
<div style="width: 160px;" class="eventlog-item-source pankow-no-mobile">{{ eventlog.source }}</div>
|
||||
<div style="flex-grow: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" v-html="eventlog.details"></div>
|
||||
<div style="width: 40px; flex-shrink: 0;"><Button v-if="eventlog.raw.data.taskId" @click.stop plain small tool :href="`/logs.html?taskId=${eventlog.raw.data.taskId}`" target="_blank">Logs</Button></div>
|
||||
</div>
|
||||
<div v-show="eventlog.isOpen" class="eventlog-details" @click.stop>
|
||||
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.eventlog-item {
|
||||
border-radius: var(--pankow-border-radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eventlog-item.active,
|
||||
.eventlog-item:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-summary {
|
||||
display: flex;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.eventlog-details {
|
||||
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
|
||||
cursor: auto;
|
||||
position: relative;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.eventlog-item-source {
|
||||
flex-shrink: 0;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
overflow: hidden;
|
||||
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>
|
||||
@@ -1,144 +1,36 @@
|
||||
<script setup>
|
||||
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { eventlogSource, eventlogDetails } from '../../utils.js';
|
||||
import EventlogList from '../EventlogList.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const busy = ref(true);
|
||||
|
||||
const eventlogs = ref([]);
|
||||
const availableActions = [
|
||||
{ id: 'app.backup', label: 'Backup started' },
|
||||
{ id: 'app.backup.finish', label: 'Backup finished' },
|
||||
{ id: 'app.configure', label: 'Reconfigured' },
|
||||
{ id: 'app.install', label: 'Installed' },
|
||||
{ id: 'app.restore', label: 'Restored' },
|
||||
{ id: 'app.uninstall', label: 'Uninstalled' },
|
||||
{ id: 'app.update', label: 'Update started' },
|
||||
{ id: 'app.update.finish', label: 'Update finished' },
|
||||
{ id: 'app.login', label: 'Log in' },
|
||||
{ id: 'app.oom', label: 'Out of memory' },
|
||||
{ id: 'app.down', label: 'Down' },
|
||||
{ id: 'app.up', label: 'Up' },
|
||||
{ id: 'app.start', label: 'Started' },
|
||||
{ id: 'app.stop', label: 'Stopped' },
|
||||
{ id: 'app.restart', label: 'Restarted' },
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await appsModel.getEvents(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = result.map(e => {
|
||||
return {
|
||||
id: Symbol(),
|
||||
raw: e,
|
||||
details: eventlogDetails(e, props.app),
|
||||
source: eventlogSource(e, props.app),
|
||||
};
|
||||
});
|
||||
|
||||
busy.value = false;
|
||||
});
|
||||
async function fetchPage(filter, page, perPage) {
|
||||
return appsModel.getEvents(props.app.id, filter, page, perPage);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="eventlog-list pankow-no-desktop">
|
||||
<div class="eventlog-list-item" v-for="eventlog in eventlogs" :key="eventlog.id" :class="{ 'active': eventlog.isOpen }">
|
||||
<div @click="eventlog.isOpen = !eventlog.isOpen" style="display: flex; justify-content: space-between; padding: 0 10px" >
|
||||
<div style="white-space: nowrap;">
|
||||
{{ prettyLongDate(eventlog.raw.creationTime) }}
|
||||
<b style="margin-left: 10px">{{ eventlog.raw.action }}</b>
|
||||
</div>
|
||||
<div>{{ eventlog.source }}</div>
|
||||
</div>
|
||||
<div v-show="eventlog.isOpen">
|
||||
<div class="eventlog-details" style="margin-top: 10px; padding-top: 5px">
|
||||
<div v-if="eventlog.raw.source.ip" class="eventlog-source">Source IP: <span @click="onCopySource(eventlog)">{{ eventlog.raw.source.ip }}</span></div>
|
||||
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="eventlog-table pankow-no-mobile">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 160px">{{ $t('eventlog.time') }}</th>
|
||||
<th style="width: 15%">{{ $t('eventlog.source') }}</th>
|
||||
<th style="word-break: break-all; overflow-wrap: anywhere;">{{ $t('eventlog.details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="eventlog in eventlogs" :key="eventlog.id">
|
||||
<tr @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen }" >
|
||||
<td style="white-space: nowrap;">{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
|
||||
<td>{{ eventlog.source }}</td>
|
||||
<td v-html="eventlog.details"></td>
|
||||
</tr>
|
||||
<tr v-show="eventlog.isOpen">
|
||||
<td colspan="3" class="eventlog-details">
|
||||
<div v-if="eventlog.raw.source.ip" class="eventlog-source">Source IP: <span @click="onCopySource(eventlog)">{{ eventlog.raw.source.ip }}</span></div>
|
||||
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<EventlogList :fetch-page="fetchPage" :app="app" :available-actions="availableActions" :show-toolbar="false" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.eventlog-table {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
border-spacing: 0px;
|
||||
}
|
||||
|
||||
.eventlog-table th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.active,
|
||||
.eventlog-table tbody tr:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-table th,
|
||||
.eventlog-table td {
|
||||
padding: 10px 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);
|
||||
font-size: 13px;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.eventlog-list-item.active {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-list-item {
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -313,10 +313,10 @@ function create() {
|
||||
if (result.status !== 202) return [result];
|
||||
return [null];
|
||||
},
|
||||
async getEvents(id) {
|
||||
async getEvents(id, filter = {}, page = 1, per_page = 100) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/eventlog`, { page: 1, per_page: 100, access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/eventlog`, { ...filter, page, per_page, access_token: accessToken });
|
||||
} catch (e) {
|
||||
return [e];
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
<script setup>
|
||||
|
||||
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';
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import EventlogList from '../components/EventlogList.vue';
|
||||
import EventlogsModel from '../models/EventlogsModel.js';
|
||||
import { eventlogDetails, eventlogSource } from '../utils.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const eventlogsModel = EventlogsModel.create();
|
||||
|
||||
function getApp(id) {
|
||||
return apps.value.find(a => a.id === id);
|
||||
}
|
||||
const eventlogList = useTemplateRef('eventlogList');
|
||||
|
||||
const availableActions = [
|
||||
{ separator: true, label: 'App' },
|
||||
@@ -68,7 +61,6 @@ const availableActions = [
|
||||
{ id: 'user.login', label: 'Logged in' },
|
||||
{ id: 'user.login.ghost', label: 'Ghost logged in' },
|
||||
{ id: 'user.logout', label: 'Logged out' },
|
||||
// { id: 'user.transfer', label: 'Transferred' },
|
||||
{ separator: true, label: 'Groups' },
|
||||
{ id: 'group.add', label: 'Added' },
|
||||
{ id: 'group.update', label: 'Updated' },
|
||||
@@ -93,168 +85,21 @@ const availableActions = [
|
||||
{ id: 'directoryserver.configure', label: 'LDAP configured ' },
|
||||
{ id: 'externalldap.configure', label: 'External LDAP configured' },
|
||||
{ id: 'userdirectory.profileconfig.update', label: 'Profile config changed' },
|
||||
// { id: 'support.ssh', label: '' },
|
||||
// { id: 'support.ticket', label: '' },
|
||||
];
|
||||
|
||||
const refreshBusy = ref(false);
|
||||
const apps = ref([]);
|
||||
const eventlogs = ref([]);
|
||||
const highlight = useDebouncedRef('', 300);
|
||||
const page = ref(1);
|
||||
const perPage = ref(100);
|
||||
const eventlogContainer = useTemplateRef('eventlogContainer');
|
||||
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;
|
||||
async function fetchPage(filter, page, perPage) {
|
||||
return eventlogsModel.search(filter, page, perPage);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const filterFrom = ref('');
|
||||
const filterTo = ref('');
|
||||
const dateFilterPopover = useTemplateRef('dateFilterPopover');
|
||||
const dateFilterButton = useTemplateRef('dateFilterButton');
|
||||
|
||||
function onOpenDateFilter(event) {
|
||||
dateFilterPopover.value.open(event, dateFilterButton.value.$el || dateFilterButton.value);
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
highlight.value = '';
|
||||
refreshBusy.value = true;
|
||||
page.value = 1;
|
||||
|
||||
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();
|
||||
|
||||
const [error, result] = await eventlogsModel.search(filter, page.value, perPage.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = result.map(e => {
|
||||
return {
|
||||
id: Symbol(),
|
||||
raw: e,
|
||||
details: eventlogDetails(e, e.data?.appId ? getApp(e.data.appId) : null),
|
||||
source: eventlogSource(e, e.data?.appId ? getApp(e.data.appId) : null)
|
||||
};
|
||||
});
|
||||
|
||||
refreshBusy.value = false;
|
||||
}
|
||||
|
||||
async function fetchMore() {
|
||||
page.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();
|
||||
|
||||
const [error, result] = await eventlogsModel.search(filter, page.value, perPage.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
eventlogs.value = eventlogs.value.concat(result.map(e => {
|
||||
return {
|
||||
id: Symbol(),
|
||||
raw: e,
|
||||
details: eventlogDetails(e, e.appId ? getApp(e.appId) : null),
|
||||
source: eventlogSource(e, e.appId ? getApp(e.appId) : null)
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async function onScroll(event) {
|
||||
if (event.target.scrollTop + event.target.clientHeight >= event.target.scrollHeight) await fetchMore();
|
||||
}
|
||||
|
||||
watch(actions, 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('?')));
|
||||
highlight.value = params.get('search') || '';
|
||||
const search = params.get('search') || '';
|
||||
if (search && eventlogList.value) eventlogList.value.setHighlight(search);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await appsModel.list();
|
||||
if (error) console.error(error);
|
||||
else apps.value = result;
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
onHashChange();
|
||||
onRefresh();
|
||||
|
||||
while (eventlogContainer.value.scrollHeight <= eventlogContainer.value.offsetHeight) {
|
||||
await fetchMore();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -265,89 +110,9 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="content-large" style="height: 100%; overflow: hidden; display: flex; flex-direction: column;">
|
||||
<!-- cant use Section component as we need control over vertical scrolling -->
|
||||
<div class="section" style="overflow: hidden; display: flex; flex-direction: column;">
|
||||
<h1 class="section-header">
|
||||
{{ $t('eventlog.title') }}
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
</h1>
|
||||
<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" />
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<label>To</label>
|
||||
<DateTimeInput date-only v-model="filterTo" :min="filterFrom" />
|
||||
</FormGroup>
|
||||
</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, 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>
|
||||
<div style="width: 160px;" class="eventlog-item-source pankow-no-mobile">{{ eventlog.source }}</div>
|
||||
<div style="flex-grow: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" v-html="eventlog.details"></div>
|
||||
<!-- <div style="width: 160px; flex-shrink: 0; cursor: copy; overflow: hidden; text-overflow: ellipsis;" v-if="eventlog.raw.source.ip" @click="onCopySource(eventlog)">{{ eventlog.raw.source.ip }}</div> -->
|
||||
<div style="width: 40px; flex-shrink: 0;"><Button v-if="eventlog.raw.data.taskId" @click.stop plain small tool :href="`/logs.html?taskId=${eventlog.raw.data.taskId}`" target="_blank">Logs</Button></div>
|
||||
</div>
|
||||
<div v-show="eventlog.isOpen" class="eventlog-details" @click.stop>
|
||||
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="section-header">{{ $t('eventlog.title') }}</h1>
|
||||
<EventlogList ref="eventlogList" :fetch-page="fetchPage" :available-actions="availableActions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.eventlog-item {
|
||||
border-radius: var(--pankow-border-radius);
|
||||
cursor: pointer;
|
||||
/*padding: 5px 10px;*/
|
||||
}
|
||||
|
||||
.eventlog-item.active,
|
||||
.eventlog-item:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-summary {
|
||||
display: flex;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.eventlog-details {
|
||||
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
|
||||
cursor: auto;
|
||||
position: relative;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.eventlog-item-source {
|
||||
flex-shrink: 0;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
overflow: hidden;
|
||||
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