408 lines
10 KiB
Vue
408 lines
10 KiB
Vue
<style scoped>
|
|
#logView {
|
|
min-height: 72vh;
|
|
min-width: inherit;
|
|
max-width: 100%;
|
|
}
|
|
|
|
#logView > span:nth-child(even) {
|
|
color: #ffc9d4;
|
|
}
|
|
|
|
#logView > span:nth-child(odd) {
|
|
color: #e3c981;
|
|
}
|
|
|
|
.logbox {
|
|
background-color: #1f2229 !important;
|
|
box-shadow:
|
|
0 4px 8px 0 rgba(0, 0, 0, 0.2),
|
|
0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
|
min-width: 100%;
|
|
max-height: 73vh;
|
|
overflow-y: scroll;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
code {
|
|
background-color: unset;
|
|
}
|
|
|
|
.logline {
|
|
word-break: break-all;
|
|
line-height: 1.75rem;
|
|
padding: 1em;
|
|
color: #fff1b8;
|
|
}
|
|
</style>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="mt-1 columns is-multiline">
|
|
<div class="column is-12 is-clearfix is-unselectable">
|
|
<span class="title is-4">
|
|
<span class="icon"><i class="fas fa-file-lines" /></span>
|
|
Logs
|
|
</span>
|
|
<div class="is-pulled-right">
|
|
<div class="field is-grouped">
|
|
<div class="control">
|
|
<button v-if="!autoScroll" @click="scrollToBottom(false)" class="button is-primary">
|
|
<span class="icon"><i class="fas fa-arrow-down"></i></span>
|
|
<span>Go to Bottom</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="control has-icons-left" v-if="toggleFilter || query">
|
|
<input
|
|
type="search"
|
|
v-model.lazy="query"
|
|
class="input"
|
|
id="filter"
|
|
placeholder="Filter displayed content"
|
|
/>
|
|
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
|
</div>
|
|
|
|
<div class="control">
|
|
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
|
<span class="icon"><i class="fas fa-filter" /></span>
|
|
</button>
|
|
</div>
|
|
|
|
<p class="control">
|
|
<button
|
|
class="button is-purple"
|
|
@click="textWrap = !textWrap"
|
|
v-tooltip.bottom="'Toggle text wrap'"
|
|
>
|
|
<span class="icon"><i class="fas fa-text-width"></i></span>
|
|
</button>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="is-hidden-mobile">
|
|
<span class="subtitle"
|
|
>The logs are being streamed in real-time. You can scroll up to view older logs.</span
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="column is-12">
|
|
<div class="logbox is-grid" ref="logContainer" @scroll.passive="handleScroll">
|
|
<code
|
|
id="logView"
|
|
class="p-2 logline is-block"
|
|
:class="{ 'is-pre-wrap': true === textWrap, 'is-pre': false === textWrap }"
|
|
>
|
|
<span
|
|
class="is-block m-0 notification is-info is-dark has-text-centered"
|
|
v-if="reachedEnd && !query"
|
|
>
|
|
<span class="notification-title">
|
|
<span class="icon"><i class="fas fa-exclamation-triangle" /></span>
|
|
No more logs available for this file.
|
|
</span>
|
|
</span>
|
|
<span v-for="log in filteredItems" :key="log.id" class="is-block">
|
|
<template v-if="log?.datetime"
|
|
>[<span class="has-tooltip" :title="log.datetime">{{
|
|
moment(log.datetime).format('HH:mm:ss')
|
|
}}</span
|
|
>]</template
|
|
>
|
|
{{ log.line }}
|
|
</span>
|
|
<span class="is-block" v-if="filteredItems.length < 1">
|
|
<span
|
|
class="is-block m-0 notification is-warning is-dark has-text-centered"
|
|
v-if="query"
|
|
>
|
|
<span class="notification-title is-danger">
|
|
<span class="icon"><i class="fas fa-filter" /></span>
|
|
No logs match this query: <u>{{ query }}</u>
|
|
</span>
|
|
</span>
|
|
<span v-else> <span class="has-text-danger">No logs available</span></span>
|
|
</span>
|
|
</code>
|
|
<div ref="bottomMarker"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
|
import type { EventSourceMessage } from '@microsoft/fetch-event-source';
|
|
import moment from 'moment';
|
|
import { useStorage } from '@vueuse/core';
|
|
import type { log_line } from '~/types/logs';
|
|
import { parse_api_error, uri } from '~/utils';
|
|
|
|
let scrollTimeout: NodeJS.Timeout | null = null;
|
|
|
|
const toast = useNotification();
|
|
const config = useConfigStore();
|
|
const route = useRoute();
|
|
|
|
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
|
|
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker');
|
|
const textWrap = useStorage<boolean>('logs_wrap', true);
|
|
const bg_enable = useStorage<boolean>('random_bg', true);
|
|
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
|
const sseController = ref<AbortController | null>(null);
|
|
|
|
const logs = ref<Array<log_line>>([]);
|
|
const offset = ref<number>(0);
|
|
const loading = ref<boolean>(false);
|
|
const autoScroll = ref<boolean>(true);
|
|
const reachedEnd = ref<boolean>(false);
|
|
|
|
const query = ref<string>(
|
|
(() => {
|
|
const filter = route.query.filter ?? '';
|
|
if (!filter) {
|
|
return '';
|
|
}
|
|
if (typeof filter === 'string') {
|
|
return filter.trim();
|
|
}
|
|
if (Array.isArray(filter) && filter.length > 0) {
|
|
return filter[0]?.trim() ?? '';
|
|
}
|
|
return '';
|
|
})(),
|
|
);
|
|
|
|
const toggleFilter = ref(false);
|
|
watch(toggleFilter, () => {
|
|
if (!toggleFilter.value) {
|
|
query.value = '';
|
|
scrollToBottom(true);
|
|
}
|
|
});
|
|
|
|
watch(
|
|
() => config.app.file_logging,
|
|
async (v) => {
|
|
if (v) {
|
|
return;
|
|
}
|
|
await navigateTo('/');
|
|
},
|
|
);
|
|
|
|
const filteredItems = computed(() => {
|
|
const raw = query.value.trim().toLowerCase();
|
|
const contextMatch = raw.match(/context:(\d+)/);
|
|
const context = contextMatch ? parseInt(String(contextMatch[1]), 10) : 0;
|
|
const searchTerm = raw.replace(/context:\d+/, '').trim();
|
|
|
|
if (!searchTerm) {
|
|
return logs.value;
|
|
}
|
|
|
|
const result: Array<log_line> = [];
|
|
const matchedIndexes = new Set();
|
|
|
|
logs.value.forEach((log, i) => {
|
|
if (log.line.toLowerCase().includes(searchTerm)) {
|
|
for (
|
|
let j = Math.max(0, i - context);
|
|
j <= Math.min(logs.value.length - 1, i + context);
|
|
j++
|
|
) {
|
|
matchedIndexes.add(j);
|
|
}
|
|
}
|
|
});
|
|
|
|
Array.from(matchedIndexes)
|
|
.sort((a: any, b: any) => a - b)
|
|
.forEach((index: any) => {
|
|
result.push(logs.value[index] as log_line);
|
|
});
|
|
|
|
return result;
|
|
});
|
|
|
|
const fetchLogs = async () => {
|
|
loading.value = true;
|
|
|
|
if (reachedEnd.value || query.value) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const req = await request(`/api/logs?offset=${offset.value}`);
|
|
if (!req.ok) {
|
|
toast.error('Failed to fetch logs');
|
|
return;
|
|
}
|
|
|
|
const response = await req.json();
|
|
if (response.error) {
|
|
toast.error(response.error);
|
|
return;
|
|
}
|
|
|
|
const lines = response.logs ?? [];
|
|
|
|
if (lines.length) {
|
|
logs.value.unshift(...response.logs);
|
|
}
|
|
|
|
if (response?.next_offset) {
|
|
offset.value = response.next_offset;
|
|
}
|
|
|
|
if (response?.end_is_reached) {
|
|
reachedEnd.value = true;
|
|
}
|
|
|
|
nextTick(() => {
|
|
if (autoScroll.value && bottomMarker.value) {
|
|
bottomMarker.value.scrollIntoView({ behavior: 'auto' });
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to fetch logs:', err);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleScroll = () => {
|
|
if (!logContainer.value || query.value) {
|
|
return;
|
|
}
|
|
|
|
const container = logContainer.value;
|
|
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50;
|
|
const nearTop = container.scrollTop < 50;
|
|
|
|
autoScroll.value = nearBottom;
|
|
|
|
if (nearTop && !loading.value && !scrollTimeout) {
|
|
scrollTimeout = setTimeout(async () => {
|
|
const previousHeight = container.scrollHeight;
|
|
await fetchLogs();
|
|
nextTick(() => {
|
|
const newHeight = container.scrollHeight;
|
|
container.scrollTop += newHeight - previousHeight;
|
|
});
|
|
scrollTimeout = null;
|
|
}, 300);
|
|
}
|
|
};
|
|
|
|
const scrollToBottom = (fast = false) => {
|
|
autoScroll.value = true;
|
|
nextTick(() => {
|
|
if (bottomMarker.value) {
|
|
bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto' : 'smooth' });
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleStreamMessage = (event: EventSourceMessage) => {
|
|
if ('log_lines' !== event.event) {
|
|
return;
|
|
}
|
|
|
|
if (!event.data) {
|
|
return;
|
|
}
|
|
|
|
let payload: log_line | null = null;
|
|
try {
|
|
payload = JSON.parse(event.data) as log_line;
|
|
} catch {
|
|
payload = null;
|
|
}
|
|
|
|
if (!payload) {
|
|
return;
|
|
}
|
|
|
|
logs.value.push(payload);
|
|
|
|
nextTick(() => {
|
|
if (autoScroll.value && bottomMarker.value) {
|
|
bottomMarker.value.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
});
|
|
};
|
|
|
|
const startLogStream = async () => {
|
|
sseController.value?.abort();
|
|
const controller = new AbortController();
|
|
sseController.value = controller;
|
|
|
|
try {
|
|
await fetchEventSource(uri('/api/logs/stream'), {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'text/event-stream',
|
|
},
|
|
credentials: 'same-origin',
|
|
signal: controller.signal,
|
|
onopen: async (response) => {
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
let message = response.statusText || 'Failed to start log stream.';
|
|
try {
|
|
message = await parse_api_error(response.clone().json());
|
|
} catch {
|
|
try {
|
|
const text = await response.text();
|
|
if (text) {
|
|
message = text;
|
|
}
|
|
} catch {
|
|
message = response.statusText || 'Failed to start log stream.';
|
|
}
|
|
}
|
|
throw new Error(message);
|
|
},
|
|
onmessage: handleStreamMessage,
|
|
onerror: (error) => {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
console.error('Log stream error:', error);
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (!controller.signal.aborted) {
|
|
console.error('Log stream error:', error);
|
|
}
|
|
} finally {
|
|
if (controller === sseController.value) {
|
|
sseController.value = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
onMounted(async () => {
|
|
await fetchLogs();
|
|
await startLogStream();
|
|
if (bg_enable.value) {
|
|
document.querySelector('body')?.setAttribute('style', `opacity: 1.0`);
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
sseController.value?.abort();
|
|
if (bg_enable.value) {
|
|
document.querySelector('body')?.setAttribute('style', `opacity: ${bg_opacity.value}`);
|
|
}
|
|
if (scrollTimeout) clearTimeout(scrollTimeout);
|
|
});
|
|
|
|
useHead({ title: 'Logs' });
|
|
</script>
|