ytptube/ui/app/pages/logs.vue
2026-02-20 22:46:30 +03:00

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>