Increase log retrieval limit and enhance log pagination metadata

This commit is contained in:
arabcoders 2025-04-10 02:32:38 +03:00
parent 17dd007ff0
commit bcdc4ee06e
3 changed files with 92 additions and 66 deletions

View file

@ -749,19 +749,22 @@ class HttpAPI(Common):
) )
offset = int(request.query.get("offset", 0)) offset = int(request.query.get("offset", 0))
limit = int(request.query.get("limit", 50)) limit = int(request.query.get("limit", 100))
if limit < 1 or limit > 150: if limit < 1 or limit > 150:
limit = 50 limit = 50
logs_data = await read_logfile(
file=os.path.join(self.config.config_path, "logs", "app.log"),
offset=offset,
limit=limit,
)
return web.json_response( return web.json_response(
data={ data={
"logs": await read_logfile( "logs": logs_data["logs"],
file=os.path.join(self.config.config_path, "logs", "app.log"),
offset=offset,
limit=limit,
),
"offset": offset, "offset": offset,
"limit": limit, "limit": limit,
"next_offset": logs_data["next_offset"],
"end_is_reached": logs_data["end_is_reached"],
}, },
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
dumps=self.encoder.encode, dumps=self.encoder.encode,

View file

@ -942,54 +942,64 @@ def strip_newline(string: str) -> str:
return res.strip() if res else "" return res.strip() if res else ""
async def read_logfile(file: str, offset: int = 0, limit: int = 50) -> list[str]: async def read_logfile(file: str, offset: int = 0, limit: int = 50) -> dict:
""" """
Read log file and return limited lines from the end. Read a log file and return a set of log lines along with pagination metadata.
Args: Args:
file (str): The log file path. file (str): The log file path.
offset (int): The number of lines to skip from the end. offset (int): Number of lines to skip from the end (newer entries).
limit (int): The number of lines to read. limit (int): Number of lines to return.
Returns: Returns:
list[str]: A list of log lines. dict: A dictionary containing:
- logs: List of log entries.
- next_offset: Offset for the next page or None.
- end_is_reached: True if there are no older logs.
""" """
from anyio import open_file from anyio import open_file
if not os.path.exists(file):
return []
from hashlib import sha256 from hashlib import sha256
if not pathlib.Path(file).exists():
return {"logs": [], "next_offset": None, "end_is_reached": True}
result = []
try: try:
async with await open_file(file, "rb") as f: async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END) await f.seek(0, os.SEEK_END)
size = await f.tell() file_size = await f.tell()
block_size = 1024 block_size = 1024
block_end = size block_end = file_size
buffer = b"" buffer = b""
lines = [] lines = []
while len(lines) < (offset + limit) and block_end > 0: required_count = offset + limit + 1
while len(lines) < required_count and block_end > 0:
block_start = max(0, block_end - block_size) block_start = max(0, block_end - block_size)
await f.seek(block_start) await f.seek(block_start)
chunk = await f.read(block_end - block_start) chunk = await f.read(block_end - block_start)
buffer = chunk + buffer buffer = chunk + buffer # prepend the chunk
lines = buffer.splitlines() lines = buffer.splitlines()
block_end = block_start block_end = block_start
selected_lines = lines[-(offset + limit) :] if offset else lines[-limit:] if len(lines) > offset + limit:
if offset: next_offset = offset + limit
selected_lines = selected_lines[:-offset] or [] end_is_reached = False
else:
next_offset = None
end_is_reached = True
if offset:
selected_lines = lines[-(offset + limit) : -offset]
else:
selected_lines = lines[-limit:]
result = []
for line in selected_lines: for line in selected_lines:
line_bytes = line if isinstance(line, bytes) else line.encode() line_bytes = line if isinstance(line, bytes) else line.encode()
msg = line.decode(errors="replace") msg = line.decode(errors="replace")
dt_match = DATETIME_PATTERN.match(msg) dt_match = DATETIME_PATTERN.match(msg)
result.append( result.append(
{ {
"id": sha256(line_bytes).hexdigest(), "id": sha256(line_bytes).hexdigest(),
@ -998,10 +1008,9 @@ async def read_logfile(file: str, offset: int = 0, limit: int = 50) -> list[str]
} }
) )
return result return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
except Exception: except Exception:
return [] return {"logs": [], "next_offset": None, "end_is_reached": True}
async def tail_log(file: str, emitter: callable, sleep_time: float = 0.5): async def tail_log(file: str, emitter: callable, sleep_time: float = 0.5):
@ -1019,7 +1028,7 @@ async def tail_log(file: str, emitter: callable, sleep_time: float = 0.5):
from anyio import open_file from anyio import open_file
if not os.path.exists(file): if not pathlib(file).exists():
return return
async with await open_file(file, "rb") as f: async with await open_file(file, "rb") as f:

View file

@ -14,7 +14,7 @@
} }
.logbox { .logbox {
background-color: #333; 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); 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%; min-width: 100%;
max-height: 73vh; max-height: 73vh;
@ -22,13 +22,13 @@
overflow-x: auto; overflow-x: auto;
} }
div.logbox pre { code {
background-color: rgb(31, 34, 41); background-color: unset;
} }
.logline { .logline {
word-break: break-all; word-break: break-all;
line-height: 2.3em; line-height: 1.75rem;
padding: 1em; padding: 1em;
color: #fff1b8; color: #fff1b8;
} }
@ -39,13 +39,13 @@ div.logbox pre {
<div class="mt-1 columns is-multiline"> <div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable"> <div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4"> <span class="title is-4">
<span class="icon"><i class="fas fa-file-lines" :class="{ 'fa-spin': loading }" /></span> <span class="icon"><i class="fas fa-file-lines" /></span>
Logs Logs
</span> </span>
<div class="is-pulled-right"> <div class="is-pulled-right">
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control"> <div class="control">
<button v-if="!autoScroll" @click="scrollToBottom" class="button is-primary"> <button v-if="!autoScroll" @click="scrollToBottom(false)" class="button is-primary">
<span class="icon"><i class="fas fa-arrow-down"></i></span> <span class="icon"><i class="fas fa-arrow-down"></i></span>
<span>Go to Bottom</span> <span>Go to Bottom</span>
</button> </button>
@ -73,26 +73,42 @@ div.logbox pre {
</div> </div>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle">The logs are displayed in real-time. You can scroll up to view older logs.</span> <span class="subtitle">The logs are being streamed in real-time. You can scroll up to view older logs.</span>
</div> </div>
</div> </div>
</div>
<div class="column is-12"> <div class="column is-12">
<div class="logbox is-grid" ref="logContainer" @scroll.passive="handleScroll"> <div class="logbox is-grid" ref="logContainer" @scroll.passive="handleScroll">
<pre class="p-1 m-1"><code id="logView" class="p-0 logline is-block" <code id="logView" class="p-2 logline is-block"
:class="{ 'is-pre-wrap': true === textWrap, 'is-pre': false === textWrap }"><span :class="{ 'is-pre-wrap': true === textWrap, 'is-pre': false === textWrap }">
v-for="log in filteredItems" :key="log.id" class="is-block"><template <span class="is-block m-0 notification is-info is-dark has-text-centered" v-if="reachedEnd && !query">
v-if="log?.datetime">[<span class="has-tooltip" :title="log.datetime">{{ moment(log.datetime).format('HH:mm:ss') }}</span>]&nbsp;</template>{{ <span class="notification-title">
log.line }}</span><span class="is-block" v-if="filteredItems.length < 1"><span v-if="query"><span <span class="icon"><i class="fas fa-exclamation-triangle"/></span>
class="has-text-danger">No logs found for the filter: </span><span class="has-text-info">{{ query No more logs available for this file.
}}</span></span><span v-else><span class="has-text-danger">No logs available</span></span> </span>
</span></code></pre> </span>
<div ref="bottomMarker"></div> <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> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import moment from 'moment' import moment from 'moment'
import { request } from '~/utils/index' import { request } from '~/utils/index'
@ -107,13 +123,12 @@ const config = useConfigStore()
const logs = ref([]) const logs = ref([])
const offset = ref(0) const offset = ref(0)
const limit = 50
const maxLogLimit = 1000
const loading = ref(false) const loading = ref(false)
const logContainer = ref(null) const logContainer = ref(null)
const bottomMarker = ref(null) const bottomMarker = ref(null)
const autoScroll = ref(true) const autoScroll = ref(true)
const textWrap = ref(true) const textWrap = ref(true)
const reachedEnd = ref(false)
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.85) const bg_opacity = useStorage('random_bg_opacity', 0.85)
@ -122,6 +137,7 @@ const toggleFilter = ref(false)
watch(toggleFilter, () => { watch(toggleFilter, () => {
if (!toggleFilter.value) { if (!toggleFilter.value) {
query.value = '' query.value = ''
scrollToBottom(true)
} }
}); });
@ -166,14 +182,14 @@ const filteredItems = computed(() => {
}) })
const fetchLogs = async () => { const fetchLogs = async () => {
if (logs.value.length >= maxLogLimit) { loading.value = true
if (reachedEnd.value || query.value) {
return return
} }
loading.value = true
try { try {
const req = await request(`/api/logs?offset=${offset.value}&limit=${limit}`) const req = await request(`/api/logs?offset=${offset.value}`)
if (!req.ok) { if (!req.ok) {
toast.error('Failed to fetch logs'); toast.error('Failed to fetch logs');
return return
@ -189,13 +205,16 @@ const fetchLogs = async () => {
if (lines.length) { if (lines.length) {
logs.value.unshift(...response.logs) logs.value.unshift(...response.logs)
offset.value += lines.length;
if (logs.value.length > maxLogLimit) {
logs.value.splice(0, logs.value.length - maxLogLimit)
}
} }
// Auto-scroll only if the user was already at the bottom if (response?.next_offset) {
offset.value = response.next_offset
}
if (response?.end_is_reached) {
reachedEnd.value = true
}
nextTick(() => { nextTick(() => {
if (autoScroll.value && bottomMarker.value) { if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: 'auto' }) bottomMarker.value.scrollIntoView({ behavior: 'auto' })
@ -208,7 +227,6 @@ const fetchLogs = async () => {
} }
} }
const handleScroll = () => { const handleScroll = () => {
if (!logContainer.value || query.value) { if (!logContainer.value || query.value) {
return return
@ -233,11 +251,11 @@ const handleScroll = () => {
} }
} }
const scrollToBottom = () => { const scrollToBottom = (fast=false) => {
autoScroll.value = true autoScroll.value = true
nextTick(() => { nextTick(() => {
if (bottomMarker.value) { if (bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: 'smooth' }) bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto': 'smooth' })
} }
}) })
} }
@ -246,10 +264,6 @@ onMounted(async () => {
await fetchLogs() await fetchLogs()
socket.emit('subscribe', 'log_lines') socket.emit('subscribe', 'log_lines')
socket.on('log_lines', data => { socket.on('log_lines', data => {
if (logs.value.length >= maxLogLimit) {
logs.value.shift()
}
logs.value.push(data) logs.value.push(data)
nextTick(() => { nextTick(() => {