ytptube/ui/pages/logs.vue

296 lines
7.9 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>
import moment from 'moment'
import { request } from '~/utils/index'
import { ref, onMounted, nextTick } from 'vue'
import { useStorage } from '@vueuse/core'
let scrollTimeout = null
const toast = useToast()
const socket = useSocketStore()
const config = useConfigStore()
const logs = ref([])
const offset = ref(0)
const loading = ref(false)
const logContainer = ref(null)
const bottomMarker = ref(null)
const autoScroll = ref(true)
const textWrap = ref(true)
const reachedEnd = ref(false)
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85)
const query = ref(useRoute().query.filter ?? '')
const toggleFilter = ref(false)
watch(toggleFilter, () => {
if (!toggleFilter.value) {
query.value = ''
scrollToBottom(true)
}
});
watch(() => config.app.basic_mode, async v => {
if (!v) {
return
}
await navigateTo('/')
})
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(contextMatch[1], 10) : 0
const searchTerm = raw.replace(/context:\d+/, '').trim()
if (!searchTerm) return logs.value
const result = []
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, b) => a - b).forEach(index => {
result.push(logs.value[index])
})
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' })
}
})
}
onMounted(async () => {
await fetchLogs()
socket.emit('subscribe', 'log_lines')
socket.on('log_lines', data => {
logs.value.push(data)
nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
}
})
})
if (bg_enable.value) {
document.querySelector('body').setAttribute("style", `opacity: 1.0`)
}
})
onUnmounted(() => {
socket.emit('unsubscribe', 'log_lines')
socket.off('log_lines')
if (bg_enable.value) {
document.querySelector('body').setAttribute("style", `opacity: ${bg_opacity.value}`)
}
})
onBeforeUnmount(() => {
socket.emit('unsubscribe', 'log_lines')
socket.off('log_lines')
if (scrollTimeout) clearTimeout(scrollTimeout)
})
useHead({ title: 'Logs' })
</script>