ytptube/ui/app/pages/logs.vue
2026-01-21 22:07:12 +03:00

372 lines
9.8 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>