refactor: use jsonl for loggng for better log view experience

This commit is contained in:
arabcoders 2026-05-18 09:44:29 +03:00
parent d18145f1c5
commit 854f5f4b42
12 changed files with 773 additions and 101 deletions

41
API.md
View file

@ -2325,12 +2325,22 @@ Binary image data with appropriate headers
{ {
"logs": [ "logs": [
{ {
"timestamp": "2023-01-01T12:00:00Z", "id": "<uuid>",
"level": "INFO", "datetime": "2026-05-18T12:00:00.000+00:00",
"message": "...", "level": "info",
... "levelno": 20,
}, "logger": "downloads.queue",
... "message": "Download started",
"exception_message": null,
"source": {
"path": "/app/library/downloads/queue_manager.py",
"file": "queue_manager.py",
"module": "queue_manager",
"function": "start",
"line": 123
},
"fields": {}
}
], ],
"offset": 0, "offset": 0,
"limit": 100, "limit": 100,
@ -2339,6 +2349,7 @@ Binary image data with appropriate headers
} }
``` ```
- Returns `404 Not Found` if file logging is not enabled. - Returns `404 Not Found` if file logging is not enabled.
- `fields` contains scalar `logging.extra` values only. Exceptions are returned in `exception` when present.
--- ---
@ -2352,9 +2363,21 @@ Binary image data with appropriate headers
**Event Payload**: **Event Payload**:
```json ```json
{ {
"id": "<sha256>", "id": "<uuid>",
"line": "<log line>", "datetime": "2026-05-18T12:00:00.000+00:00",
"datetime": "2024-01-01T12:00:00.000000+00:00" "level": "info",
"levelno": 20,
"logger": "downloads.queue",
"message": "Download started",
"exception_message": null,
"source": {
"path": "/app/library/downloads/queue_manager.py",
"file": "queue_manager.py",
"module": "queue_manager",
"function": "start",
"line": 123
},
"fields": {}
} }
``` ```

View file

@ -20,6 +20,7 @@ LOG: logging.Logger = logging.getLogger("downloads.extractor")
def _ytdlp_logger(target: logging.Logger): def _ytdlp_logger(target: logging.Logger):
def _log(level: int, msg: str, *args: Any, **kwargs: Any) -> None: def _log(level: int, msg: str, *args: Any, **kwargs: Any) -> None:
kwargs.setdefault("stacklevel", 4)
if level <= logging.DEBUG and isinstance(msg, str) and msg.startswith("[debug] "): if level <= logging.DEBUG and isinstance(msg, str) and msg.startswith("[debug] "):
target.debug(msg.removeprefix("[debug] "), *args, **kwargs) target.debug(msg.removeprefix("[debug] "), *args, **kwargs)
return return

View file

@ -162,11 +162,11 @@ class TestYtdlpLogger:
_ytdlp_logger(logger)(logging.DEBUG, "[debug] hello") _ytdlp_logger(logger)(logging.DEBUG, "[debug] hello")
logger.debug.assert_called_once_with("hello") logger.debug.assert_called_once_with("hello", stacklevel=4)
def test_screen_style_debug_uses_info(self) -> None: def test_screen_style_debug_uses_info(self) -> None:
logger = MagicMock() logger = MagicMock()
_ytdlp_logger(logger)(logging.DEBUG, "screen line") _ytdlp_logger(logger)(logging.DEBUG, "screen line")
logger.info.assert_called_once_with("screen line") logger.info.assert_called_once_with("screen line", stacklevel=4)

View file

@ -90,6 +90,7 @@ class TestLogWrapper:
assert len(cap.records) == 1 assert len(cap.records) == 1
assert cap.records[0].levelno == logging.INFO assert cap.records[0].levelno == logging.INFO
assert cap.records[0].getMessage() == "hello X" assert cap.records[0].getMessage() == "hello X"
assert cap.records[0].funcName == "test_level_filtering_and_dispatch"
assert len(calls) == 0 assert len(calls) == 0
# WARNING hits both # WARNING hits both

View file

@ -131,7 +131,9 @@ class LogWrapper:
continue continue
if target.logger: if target.logger:
target.target.log(level, msg, *args, **kwargs) log_kwargs = {**kwargs}
log_kwargs.setdefault("stacklevel", 3)
target.target.log(level, msg, *args, **log_kwargs)
else: else:
target.target(level, msg, *args, **kwargs) target.target(level, msg, *args, **kwargs)

View file

@ -2,6 +2,7 @@ import base64
import copy import copy
import glob import glob
import ipaddress import ipaddress
import json
import logging import logging
import os import os
import re import re
@ -40,6 +41,67 @@ class FileLogFormatter(logging.Formatter):
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds") return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
LOG_RECORD_ATTRS: set[str] = set(logging.makeLogRecord({}).__dict__) | {"asctime", "message"}
class JsonLogFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
data: dict[str, Any] = {
"id": str(uuid.uuid4()),
"datetime": self.formatTime(record),
"level": record.levelname.lower(),
"levelno": record.levelno,
"logger": record.name,
"message": record.getMessage(),
"source": {
"path": record.pathname,
"file": record.filename,
"module": record.module,
"function": record.funcName,
"line": record.lineno,
},
"process": {"id": record.process, "name": record.processName},
"thread": {"id": record.thread, "name": record.threadName},
"fields": self._extra(record),
}
if record.exc_info:
data["exception"] = self.formatException(record.exc_info)
data["exception_message"] = self._exception_message(record.exc_info)
if record.stack_info:
data["stack"] = self.formatStack(record.stack_info)
return json.dumps(data, ensure_ascii=False, default=str)
def formatTime(self, record, datefmt=None): # noqa: ARG002, N802
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
@staticmethod
def _extra(record: logging.LogRecord) -> dict[str, Any]:
extra: dict[str, Any] = {}
for key, value in record.__dict__.items():
if key in LOG_RECORD_ATTRS or key.startswith("_"):
continue
if isinstance(value, str | int | float | bool) or value is None:
extra[key] = value
return extra
@staticmethod
def _exception_message(
exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None],
) -> str | None:
exc = exc_info[1]
if exc is None:
return None
name = exc.__class__.__name__
msg = str(exc).strip()
return f"{name}: {msg}" if msg else name
def timed_lru_cache(ttl_seconds: int, max_size: int = 128): def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
""" """
Decorator that applies an LRU cache with a time-to-live (TTL) to a function. Decorator that applies an LRU cache with a time-to-live (TTL) to a function.

View file

@ -13,7 +13,7 @@ import coloredlogs
from dotenv import load_dotenv from dotenv import load_dotenv
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import FileLogFormatter from .Utils import JsonLogFormatter
from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION
if TYPE_CHECKING: if TYPE_CHECKING:
@ -466,14 +466,14 @@ class Config(metaclass=Singleton):
loggingPath.mkdir(parents=True, exist_ok=True) loggingPath.mkdir(parents=True, exist_ok=True)
handler = TimedRotatingFileHandler( handler = TimedRotatingFileHandler(
filename=loggingPath / "app.log", filename=loggingPath / "app.jsonl",
when="midnight", when="midnight",
backupCount=3, backupCount=3,
encoding="utf-8", encoding="utf-8",
) )
handler.setLevel(log_level_file) handler.setLevel(log_level_file)
formatter = FileLogFormatter("%(asctime)s [%(levelname)s.%(name)s]: %(message)s") formatter = JsonLogFormatter()
handler.setFormatter(formatter) handler.setFormatter(formatter)
logging.getLogger().addHandler(handler) logging.getLogger().addHandler(handler)

View file

@ -1,7 +1,7 @@
import asyncio import asyncio
import json
import logging import logging
import os import os
import re
from pathlib import Path from pathlib import Path
from aiohttp import web from aiohttp import web
@ -13,8 +13,38 @@ from app.library.router import route
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Match ISO8601." def _parse_jsonl_line(line: bytes | str) -> dict | None:
raw: str = line.decode(errors="replace") if isinstance(line, bytes) else line
raw = raw.rstrip("\r\n")
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
required = ("id", "datetime", "level", "logger", "message")
if any(not payload.get(key) for key in required):
return None
result: dict = {
"id": str(payload["id"]),
"datetime": str(payload["datetime"]),
"level": str(payload["level"]),
"logger": str(payload["logger"]),
"message": str(payload["message"]).strip(),
}
for key in ("levelno", "source", "process", "thread", "fields", "exception", "exception_message", "stack"):
if key not in payload:
continue
result[key] = payload[key]
return result
async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict: async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
@ -33,8 +63,6 @@ async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
- end_is_reached: True if there are no older logs. - end_is_reached: True if there are no older logs.
""" """
from hashlib import sha256
from anyio import open_file from anyio import open_file
if not file.exists(): if not file.exists():
@ -68,17 +96,8 @@ async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
next_offset = None next_offset = None
end_is_reached = True end_is_reached = True
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]: selected = lines[-(offset + limit) : -offset] if offset else lines[-limit:]
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode() result.extend(log for line in selected if (log := _parse_jsonl_line(line)))
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
result.append(
{
"id": sha256(line_bytes).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached} return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
except Exception: except Exception:
@ -95,8 +114,6 @@ async def _tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
sleep_time (float): The time to sleep between reads. sleep_time (float): The time to sleep between reads.
""" """
from hashlib import sha256
from anyio import open_file from anyio import open_file
if not file.exists(): if not file.exists():
@ -111,16 +128,8 @@ async def _tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
await asyncio.sleep(sleep_time) await asyncio.sleep(sleep_time)
continue continue
msg: str = line.decode(errors="replace") if log := _parse_jsonl_line(line):
dt_match: re.Match[str] | None = DT_PATTERN.match(msg) await emitter(log)
await emitter(
{
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
except Exception as e: except Exception as e:
LOG.error(f"Error while tailing log file '{file!s}': {e!s}") LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
return return
@ -149,7 +158,7 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
limit = 50 limit = 50
logs_data = await _read_logfile( logs_data = await _read_logfile(
file=Path(config.config_path) / "logs" / "app.log", file=Path(config.config_path) / "logs" / "app.jsonl",
offset=offset, offset=offset,
limit=limit, limit=limit,
) )
@ -175,7 +184,7 @@ async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Res
status=web.HTTPNotFound.status_code, status=web.HTTPNotFound.status_code,
) )
log_file = Path(config.config_path) / "logs" / "app.log" log_file = Path(config.config_path) / "logs" / "app.jsonl"
if not log_file.exists(): if not log_file.exists():
return web.json_response( return web.json_response(
data={"error": "Log file is not available."}, data={"error": "Log file is not available."},

View file

@ -1,8 +1,11 @@
import asyncio import asyncio
import copy import copy
import importlib import importlib
import json
import logging
import re import re
import shutil import shutil
import sys
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -14,6 +17,7 @@ import pytest
from app.features.ytdlp.utils import arg_converter from app.features.ytdlp.utils import arg_converter
from app.library.Utils import ( from app.library.Utils import (
FileLogFormatter, FileLogFormatter,
JsonLogFormatter,
calc_download_path, calc_download_path,
check_id, check_id,
clean_item, clean_item,
@ -278,6 +282,38 @@ class TestFileLogFormatter:
assert isinstance(formatter, FileLogFormatter) assert isinstance(formatter, FileLogFormatter)
class TestJsonLogFormatter:
def test_basic(self):
formatter = JsonLogFormatter()
record = logging.LogRecord("test.logger", logging.WARNING, __file__, 123, "hello %s", ("world",), None)
record.download_id = "abc"
record.payload = {"ignored": True}
data = json.loads(formatter.format(record))
assert uuid.UUID(data["id"])
assert data["level"] == "warning"
assert data["levelno"] == logging.WARNING
assert data["logger"] == "test.logger"
assert data["message"] == "hello world"
assert data["fields"] == {"download_id": "abc"}
assert data["source"]["line"] == 123
def test_exception(self):
formatter = JsonLogFormatter()
try:
raise ValueError("bad")
except ValueError:
record = logging.LogRecord("test", logging.ERROR, __file__, 1, "failed", (), sys.exc_info())
data = json.loads(formatter.format(record))
assert data["message"] == "failed"
assert data["exception_message"] == "ValueError: bad"
assert "ValueError: bad" in data["exception"]
class TestCalcDownloadPath: class TestCalcDownloadPath:
"""Test the calc_download_path function.""" """Test the calc_download_path function."""
@ -1524,12 +1560,68 @@ class TestReadLogfile:
def test_read_logfile_with_content(self): def test_read_logfile_with_content(self):
"""Test reading log file with content.""" """Test reading log file with content."""
self.log_file.write_text("line 1\nline 2\nline 3\n") lines = [
{
"id": "log-1",
"datetime": "2026-01-01T00:00:01.000+00:00",
"level": "info",
"logger": "test",
"message": "line 1",
},
{
"id": "log-2",
"datetime": "2026-01-01T00:00:02.000+00:00",
"level": "warning",
"logger": "test",
"message": "line 2",
},
{
"id": "log-3",
"datetime": "2026-01-01T00:00:03.000+00:00",
"level": "error",
"logger": "test",
"message": "line 3",
},
]
self.log_file.write_text("\n".join(json.dumps(line) for line in lines) + "\n")
async def test(): async def test():
result = await _read_logfile(self.log_file, limit=2) result = await _read_logfile(self.log_file, limit=2)
assert isinstance(result, dict) assert isinstance(result, dict)
assert "logs" in result assert "logs" in result
assert [line["id"] for line in result["logs"]] == ["log-2", "log-3"]
assert all("line" not in line for line in result["logs"])
assert [line["message"] for line in result["logs"]] == ["line 2", "line 3"]
assert result["logs"][0]["level"] == "warning"
assert result["next_offset"] == 2
assert result["end_is_reached"] is False
asyncio.run(test())
def test_read_logfile_malformed(self):
self.log_file.write_text("not-json\n")
async def test():
result = await _read_logfile(self.log_file, limit=1)
assert result["logs"] == []
asyncio.run(test())
def test_read_logfile_missing_id(self):
self.log_file.write_text(json.dumps({"message": "ignored"}) + "\n")
async def test():
result = await _read_logfile(self.log_file, limit=1)
assert result["logs"] == []
asyncio.run(test())
def test_read_logfile_missing_required(self):
self.log_file.write_text(json.dumps({"id": "ignored", "message": "ignored"}) + "\n")
async def test():
result = await _read_logfile(self.log_file, limit=1)
assert result["logs"] == []
asyncio.run(test()) asyncio.run(test())
@ -1564,6 +1656,40 @@ class TestTailLog:
asyncio.run(test()) asyncio.run(test())
def test_tail_log_jsonl(self):
self.log_file.write_text("")
emitted_lines = []
async def emitter(line):
emitted_lines.append(line)
raise asyncio.CancelledError
async def test():
task = asyncio.create_task(_tail_log(self.log_file, emitter, sleep_time=0.01))
await asyncio.sleep(0.02)
self.log_file.write_text(
json.dumps(
{
"id": "tail-1",
"datetime": "2026-01-01T00:00:00.000+00:00",
"level": "info",
"logger": "tail",
"message": "live line",
}
)
+ "\n"
)
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=1)
asyncio.run(test())
assert emitted_lines[0]["message"] == "live line"
assert emitted_lines[0]["id"] == "tail-1"
assert "line" not in emitted_lines[0]
assert emitted_lines[0]["level"] == "info"
class TestLoadCookies: class TestLoadCookies:
"""Test the load_cookies function.""" """Test the load_cookies function."""

View file

@ -64,6 +64,22 @@
Wrap Wrap
</UButton> </UButton>
<USelect
v-model="selectedLevels"
:items="levelFilterItems"
value-key="value"
label-key="label"
multiple
size="sm"
icon="i-lucide-list-filter"
class="order-last w-full sm:order-0 sm:w-48"
:ui="{ content: 'min-w-48' }"
>
<template #default>
{{ levelFilterLabel }}
</template>
</USelect>
<UInput <UInput
v-if="toggleFilter || query" v-if="toggleFilter || query"
id="filter" id="filter"
@ -97,6 +113,22 @@
</div> </div>
</div> </div>
<div
v-if="canLoadFilteredHistory"
class="flex justify-center border-b border-default/40 px-4 py-3"
>
<UButton
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-history"
:loading="loading"
@click="fetchLogs(true)"
>
Load older lines into filter
</UButton>
</div>
<template v-if="filteredLogs.length > 0"> <template v-if="filteredLogs.length > 0">
<article <article
v-for="(entry, index) in filteredLogs" v-for="(entry, index) in filteredLogs"
@ -109,20 +141,42 @@
textWrap ? 'w-full' : 'w-max min-w-full', textWrap ? 'w-full' : 'w-max min-w-full',
]" ]"
> >
<span <p :class="logLineClass()">
:class="[ <span class="inline-flex items-center gap-2 align-middle">
'mt-[0.45rem] inline-flex size-2 shrink-0 rounded-full', <UTooltip :text="logTimeTitle(entry.log.datetime)">
logLevelDotClass(entry.level), <span class="inline text-[11px] font-semibold text-toned cursor-pointer">
]" {{ logTimeLabel(entry.log.datetime) }}
/> </span>
<p :class="logLineClass(entry.level)"> </UTooltip>
<span <UButton
class="mr-2 inline text-[11px] font-semibold text-toned cursor-pointer" color="neutral"
:title="logTimeTitle(entry.log.datetime)" variant="ghost"
> size="xs"
{{ logTimeLabel(entry.log.datetime) }} icon="i-lucide-panel-right-open"
aria-label="Open log details"
class="inline-flex align-[-0.2em] opacity-70 hover:opacity-100"
@click="openLogDetails(entry.log)"
/>
<span
:class="logLevelBadgeClass(getLogLevel(entry.log.level))"
@click="openLogDetails(entry.log)"
>
<UIcon
:name="LOG_LEVEL_ICON[getLogLevel(entry.log.level)]"
class="size-3"
/>
{{ getLogLevel(entry.log.level) }}
</span>
<span
v-if="entry.log.logger"
class="inline text-[11px] font-semibold text-toned"
>[{{ entry.log.logger }}]</span
>
</span>
<span class="ml-2">{{ entry.log.message }}</span>
<span v-if="entry.log.exception_message" class="ml-1 text-error/90">
: {{ entry.log.exception_message }}
</span> </span>
{{ entry.log.line }}
</p> </p>
</div> </div>
</article> </article>
@ -133,21 +187,17 @@
class="flex min-h-[55vh] flex-col items-center justify-center gap-3 px-6 py-8 text-center font-sans" class="flex min-h-[55vh] flex-col items-center justify-center gap-3 px-6 py-8 text-center font-sans"
> >
<UIcon <UIcon
:name="query ? 'i-lucide-filter-x' : 'i-lucide-circle-off'" :name="hasActiveFilter ? 'i-lucide-filter-x' : 'i-lucide-circle-off'"
class="size-6 text-toned" class="size-6 text-toned"
/> />
<div class="space-y-1"> <div class="space-y-1">
<p class="text-sm font-medium text-default"> <p class="text-sm font-medium text-default">
{{ query ? 'No logs match this query' : 'No log lines available' }} {{ emptyStateTitle }}
</p> </p>
<p class="text-sm text-toned"> <p class="text-sm text-toned">
{{ {{ emptyStateDescription }}
query
? `No log lines found for the query: ${query}. Please try a different search term.`
: 'No log lines are available yet.'
}}
</p> </p>
</div> </div>
</div> </div>
@ -155,6 +205,182 @@
</div> </div>
</template> </template>
</UPageCard> </UPageCard>
<UModal v-model:open="detailsOpen" title="Log details" :ui="detailsModalUi">
<template #body>
<div v-if="selectedLog" class="space-y-5">
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<UBadge
:color="LOG_LEVEL_COLOR[getLogLevel(selectedLog.level)]"
variant="soft"
size="sm"
class="uppercase"
>
<UIcon
:name="LOG_LEVEL_ICON[getLogLevel(selectedLog.level)]"
class="mr-1 size-3.5"
/>
{{ getLogLevel(selectedLog.level) }}
</UBadge>
<UBadge v-if="selectedLog.logger" color="neutral" variant="soft" size="sm">
{{ selectedLog.logger }}
</UBadge>
<span class="text-xs text-toned">{{ logTimeTitle(selectedLog.datetime) }}</span>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-2">
<UButton
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-copy"
@click="copyLogMessage(selectedLog)"
>
Message
</UButton>
<UButton
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-braces"
@click="copyLogRaw(selectedLog)"
>
JSON
</UButton>
</div>
<div class="min-w-0 space-y-2 sm:col-span-2">
<p class="wrap-break-word w-full font-mono text-sm text-default">
{{ selectedLog.message }}
</p>
<UAlert
v-if="selectedLog.exception_message"
color="error"
variant="soft"
icon="i-lucide-badge-alert"
:title="selectedLog.exception_message"
class="w-full"
/>
</div>
</div>
<section v-if="selectedLog.exception" class="space-y-2">
<button
type="button"
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
@click="exceptionOpen = !exceptionOpen"
>
<UIcon
name="i-lucide-chevron-right"
:class="['size-4 transition-transform', exceptionOpen ? 'rotate-90' : '']"
/>
<UIcon name="i-lucide-bug" class="size-4 text-error" />
Exception
</button>
<pre
v-if="exceptionOpen"
class="max-h-72 overflow-auto rounded-sm border border-error/30 bg-error/5 p-3 text-xs whitespace-pre-wrap text-error"
>{{ selectedLog.exception }}</pre
>
</section>
<section v-if="selectedLog.stack" class="space-y-2">
<h3
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned"
>
<UIcon name="i-lucide-layers" class="size-4 text-toned" />
Stack
</h3>
<pre
class="max-h-72 overflow-auto rounded-sm border border-default bg-elevated/50 p-3 text-xs whitespace-pre-wrap text-default"
>{{ selectedLog.stack }}</pre
>
</section>
<section v-if="detailRows(selectedLog).length > 0" class="space-y-2">
<button
type="button"
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
@click="sourceOpen = !sourceOpen"
>
<UIcon
name="i-lucide-chevron-right"
:class="['size-4 transition-transform', sourceOpen ? 'rotate-90' : '']"
/>
<UIcon name="i-lucide-file-code" class="size-4 text-info" />
Source
</button>
<dl v-if="sourceOpen" class="grid gap-2 sm:grid-cols-2">
<div
v-for="row in detailRows(selectedLog)"
:key="row.label"
class="rounded-sm border border-default bg-elevated/40 p-3"
>
<dt
class="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-toned"
>
<UIcon :name="row.icon" class="size-3.5" />
<span>{{ row.label }}</span>
</dt>
<dd class="mt-1 wrap-break-word font-mono text-xs text-default">{{ row.value }}</dd>
</div>
</dl>
</section>
<section v-if="fieldRows(selectedLog).length > 0" class="space-y-2">
<button
type="button"
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
@click="fieldsOpen = !fieldsOpen"
>
<UIcon
name="i-lucide-chevron-right"
:class="['size-4 transition-transform', fieldsOpen ? 'rotate-90' : '']"
/>
<UIcon name="i-lucide-tags" class="size-4 text-primary" />
Fields
</button>
<dl v-if="fieldsOpen" class="grid gap-2 sm:grid-cols-2">
<div
v-for="row in fieldRows(selectedLog)"
:key="row.label"
class="rounded-sm border border-default bg-elevated/40 p-3"
>
<dt
class="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-toned"
>
<UIcon :name="row.icon" class="size-3.5" />
<span>{{ row.label }}</span>
</dt>
<dd class="mt-1 wrap-break-word font-mono text-xs text-default">{{ row.value }}</dd>
</div>
</dl>
</section>
<section class="space-y-2">
<button
type="button"
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
@click="rawJsonOpen = !rawJsonOpen"
>
<UIcon
name="i-lucide-chevron-right"
:class="['size-4 transition-transform', rawJsonOpen ? 'rotate-90' : '']"
/>
<UIcon name="i-lucide-braces" class="size-4 text-toned" />
RAW DATA
</button>
<pre
v-if="rawJsonOpen"
class="max-h-96 overflow-auto rounded-sm border border-default bg-elevated/50 p-3 text-xs whitespace-pre-wrap text-default"
>{{ logRaw(selectedLog) }}</pre
>
</section>
</div>
</template>
</UModal>
</main> </main>
</template> </template>
@ -164,7 +390,7 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source';
import moment from 'moment'; import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { log_line } from '~/types/logs'; import type { log_line } from '~/types/logs';
import { parse_api_error, request, uri } from '~/utils'; import { copyText, parse_api_error, request, uri } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation'; import { requirePageShell } from '~/utils/topLevelNavigation';
type FilteredLogEntry = { type FilteredLogEntry = {
@ -175,23 +401,32 @@ type FilteredLogEntry = {
}; };
type LogLevel = 'debug' | 'info' | 'warning' | 'error'; type LogLevel = 'debug' | 'info' | 'warning' | 'error';
type LogLevelColor = 'neutral' | 'info' | 'warning' | 'error';
type DetailRow = {
label: string;
value: string;
icon: string;
};
type LevelFilterItem = {
label: string;
value: LogLevel;
};
const FILTER_CONTEXT_REGEX = /context:(\d+)/; const FILTER_CONTEXT_REGEX = /context:(\d+)/;
const LOG_LEVEL_PREFIX = const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warning', 'error'];
/^\s*(?:\[(debug|info|warning|warn|error|critical|fatal)(?:[.\]])|(debug|info|warning|warn|error|critical|fatal)\b(?::|\s|-))/i;
const LOG_ROW_CLASS = const LOG_ROW_CLASS =
'flex min-w-0 border-b border-default/40 bg-transparent transition-colors duration-150 last:border-b-0 hover:bg-elevated/70'; 'flex min-w-0 border-b border-default/40 bg-transparent transition-colors duration-150 last:border-b-0 hover:bg-elevated/70';
const LOG_LEVEL_DOT_CLASS: Record<LogLevel, string> = { const LOG_LEVEL_COLOR: Record<LogLevel, LogLevelColor> = {
debug: 'bg-muted', debug: 'neutral',
info: 'bg-info', info: 'info',
warning: 'bg-warning', warning: 'warning',
error: 'bg-error', error: 'error',
}; };
const LOG_LEVEL_TEXT_CLASS: Record<LogLevel, string> = { const LOG_LEVEL_ICON: Record<LogLevel, string> = {
debug: 'text-toned', debug: 'i-lucide-terminal',
info: 'text-info', info: 'i-lucide-info',
warning: 'text-warning', warning: 'i-lucide-triangle-alert',
error: 'text-error', error: 'i-lucide-circle-x',
}; };
let scrollTimeout: NodeJS.Timeout | null = null; let scrollTimeout: NodeJS.Timeout | null = null;
@ -203,13 +438,20 @@ const pageShell = requirePageShell('logs');
const logContainer = useTemplateRef<HTMLDivElement>('logContainer'); const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
const textWrap = useStorage<boolean>('logs_wrap', true); const textWrap = useStorage<boolean>('logs_wrap', true);
const exceptionOpen = useStorage<boolean>('logs_exception_open', false);
const fieldsOpen = useStorage<boolean>('logs_fields_open', true);
const rawJsonOpen = useStorage<boolean>('logs_raw_json_open', false);
const sourceOpen = useStorage<boolean>('logs_source_open', true);
const selectedLevels = useStorage<LogLevel[]>('logs_level_filter', [...LOG_LEVELS]);
const sseController = ref<AbortController | null>(null); const sseController = ref<AbortController | null>(null);
const logs = ref<Array<log_line>>([]); const logs = ref<Array<log_line>>([]);
const selectedLog = ref<log_line | null>(null);
const offset = ref(0); const offset = ref(0);
const loading = ref(false); const loading = ref(false);
const autoScroll = ref(true); const autoScroll = ref(true);
const reachedEnd = ref(false); const reachedEnd = ref(false);
const detailsOpen = ref(false);
const pageCardUi = { const pageCardUi = {
root: 'w-full min-w-0 max-w-full bg-transparent', root: 'w-full min-w-0 max-w-full bg-transparent',
@ -217,6 +459,10 @@ const pageCardUi = {
wrapper: 'w-full min-w-0 items-stretch', wrapper: 'w-full min-w-0 items-stretch',
body: 'w-full min-w-0 max-w-full overflow-hidden', body: 'w-full min-w-0 max-w-full overflow-hidden',
}; };
const detailsModalUi = {
content: 'max-w-5xl',
body: 'max-h-[75vh] overflow-y-auto',
};
const query = ref<string>( const query = ref<string>(
(() => { (() => {
@ -240,12 +486,75 @@ const query = ref<string>(
const toggleFilter = ref(Boolean(query.value)); const toggleFilter = ref(Boolean(query.value));
const normalizedQuery = computed(() => query.value.trim().toLowerCase()); const normalizedQuery = computed(() => query.value.trim().toLowerCase());
const selectedLevelSet = computed(
() => new Set(LOG_LEVELS.filter((level) => selectedLevels.value.includes(level))),
);
const hasLevelFilter = computed(() => selectedLevelSet.value.size !== LOG_LEVELS.length);
const filterContext = computed(() => { const filterContext = computed(() => {
const match = normalizedQuery.value.match(FILTER_CONTEXT_REGEX); const match = normalizedQuery.value.match(FILTER_CONTEXT_REGEX);
return match ? parseInt(match[1] ?? '0', 10) : 0; return match ? parseInt(match[1] ?? '0', 10) : 0;
}); });
const searchTerm = computed(() => normalizedQuery.value.replace(FILTER_CONTEXT_REGEX, '').trim()); const searchTerm = computed(() => normalizedQuery.value.replace(FILTER_CONTEXT_REGEX, '').trim());
const hasActiveFilter = computed(() => Boolean(searchTerm.value)); const hasTextFilter = computed(() => Boolean(searchTerm.value));
const hasActiveFilter = computed(() => hasTextFilter.value || hasLevelFilter.value);
const canLoadFilteredHistory = computed(
() => hasActiveFilter.value && !reachedEnd.value && logs.value.length > 0,
);
const levelCounts = computed<Record<LogLevel, number>>(() => {
const counts: Record<LogLevel, number> = {
debug: 0,
info: 0,
warning: 0,
error: 0,
};
logs.value.forEach((log) => {
const level = getLogLevel(log.level);
counts[level] += 1;
});
return counts;
});
const levelFilterItems = computed<LevelFilterItem[]>(() => [
...LOG_LEVELS.map((level) => ({
label: `${level.charAt(0).toUpperCase()}${level.slice(1)} (${levelCounts.value[level]})`,
value: level,
})),
]);
const levelFilterLabel = computed(() => {
if (selectedLevelSet.value.size === LOG_LEVELS.length) {
return `All levels (${logs.value.length})`;
}
if (selectedLevelSet.value.size === 0) {
return 'No levels selected';
}
return LOG_LEVELS.filter((level) => selectedLevelSet.value.has(level)).join(', ');
});
const activeFilterLabel = computed(() => {
const parts: string[] = [];
if (hasTextFilter.value) {
parts.push(`query "${searchTerm.value}"`);
}
if (hasLevelFilter.value) {
const levels = LOG_LEVELS.filter((level) => selectedLevelSet.value.has(level));
parts.push(levels.length ? `levels ${levels.join(', ')}` : 'no selected levels');
}
return parts.join(' and ');
});
const emptyStateTitle = computed(() =>
hasActiveFilter.value ? 'No logs match these filters' : 'No log lines available',
);
const emptyStateDescription = computed(() => {
if (!hasActiveFilter.value) {
return 'No log lines are available yet.';
}
return `No loaded log lines match ${activeFilterLabel.value}. Adjust filters or load older lines.`;
});
watch(toggleFilter, () => { watch(toggleFilter, () => {
if (!toggleFilter.value) { if (!toggleFilter.value) {
query.value = ''; query.value = '';
@ -264,11 +573,17 @@ watch(
}, },
); );
watch(detailsOpen, (open) => {
if (!open) {
selectedLog.value = null;
}
});
const filteredLogs = computed<FilteredLogEntry[]>(() => { const filteredLogs = computed<FilteredLogEntry[]>(() => {
if (!hasActiveFilter.value) { if (!hasActiveFilter.value) {
return logs.value.map((log) => ({ return logs.value.map((log) => ({
log, log,
level: detectLogLevel(log.line), level: getLogLevel(log.level),
isMatch: false, isMatch: false,
isContext: false, isContext: false,
})); }));
@ -279,7 +594,16 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
const matchedIndexes = new Set<number>(); const matchedIndexes = new Set<number>();
logs.value.forEach((log, index) => { logs.value.forEach((log, index) => {
if (log.line.toLowerCase().includes(searchTerm.value)) { if (!selectedLevelSet.value.has(getLogLevel(log.level))) {
return;
}
if (!hasTextFilter.value) {
visibleIndexes.add(index);
return;
}
if (searchableLog(log).includes(searchTerm.value)) {
matchedIndexes.add(index); matchedIndexes.add(index);
for ( for (
@ -296,10 +620,13 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
.sort((a, b) => a - b) .sort((a, b) => a - b)
.forEach((index) => { .forEach((index) => {
const log = logs.value[index] as log_line; const log = logs.value[index] as log_line;
if (!selectedLevelSet.value.has(getLogLevel(log.level))) {
return;
}
result.push({ result.push({
log, log,
level: detectLogLevel(log.line), level: getLogLevel(log.level),
isMatch: matchedIndexes.has(index), isMatch: matchedIndexes.has(index),
isContext: !matchedIndexes.has(index), isContext: !matchedIndexes.has(index),
}); });
@ -321,10 +648,10 @@ const scrollLogContainerToBottom = async (behavior: ScrollBehavior = 'auto'): Pr
}); });
}; };
const fetchLogs = async (): Promise<void> => { const fetchLogs = async (force = false): Promise<void> => {
loading.value = true; loading.value = true;
if (reachedEnd.value || (hasActiveFilter.value && logs.value.length > 0)) { if (reachedEnd.value || (!force && hasActiveFilter.value && logs.value.length > 0)) {
loading.value = false; loading.value = false;
return; return;
} }
@ -477,6 +804,24 @@ const logTimeLabel = (value?: string): string =>
const logTimeTitle = (value?: string): string => const logTimeTitle = (value?: string): string =>
value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp'; value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp';
const logRaw = (log: log_line): string => JSON.stringify(log, null, 2);
const searchableLog = (log: log_line): string =>
[
log.message,
log.level,
log.logger,
log.exception,
log.stack,
log.source ? JSON.stringify(log.source) : '',
log.process ? JSON.stringify(log.process) : '',
log.thread ? JSON.stringify(log.thread) : '',
log.fields ? JSON.stringify(log.fields) : '',
]
.filter(Boolean)
.join(' ')
.toLowerCase();
const getLogLevel = (level: string): LogLevel => { const getLogLevel = (level: string): LogLevel => {
switch (level.toLowerCase()) { switch (level.toLowerCase()) {
case 'info': case 'info':
@ -493,21 +838,20 @@ const getLogLevel = (level: string): LogLevel => {
} }
}; };
const detectLogLevel = (line: string): LogLevel => { const logLevelBadgeClass = (level: LogLevel): string[] => [
const match = line.match(LOG_LEVEL_PREFIX); 'inline-flex items-center gap-1.5 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide cursor-pointer',
const level = match?.[1] ?? match?.[2]; level === 'debug' ? 'bg-muted/40 text-muted' : '',
level === 'info' ? 'bg-info/10 text-info' : '',
level === 'warning' ? 'bg-warning/10 text-warning' : '',
level === 'error' ? 'bg-error/10 text-error' : '',
];
return level ? getLogLevel(level) : 'debug'; const logLineClass = (): string[] => [
};
const logLevelDotClass = (level: LogLevel): string => LOG_LEVEL_DOT_CLASS[level];
const logLineClass = (level: LogLevel): string[] => [
'flex-1', 'flex-1',
textWrap.value textWrap.value
? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]' ? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]'
: 'min-w-max whitespace-pre', : 'min-w-max whitespace-pre',
LOG_LEVEL_TEXT_CLASS[level], 'text-default',
]; ];
const logRowClass = (entry: FilteredLogEntry, index: number): string[] => { const logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
@ -530,6 +874,82 @@ const logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
return classes; return classes;
}; };
const openLogDetails = (log: log_line): void => {
selectedLog.value = log;
detailsOpen.value = true;
};
const formatDetailValue = (value: unknown): string => {
if (value === undefined || value === null || value === '') {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
};
const compactRows = (rows: Array<{ label: string; value: unknown; icon: string }>): DetailRow[] =>
rows
.map((row) => ({
label: row.label,
value: formatDetailValue(row.value),
icon: row.icon,
}))
.filter((row) => Boolean(row.value));
const formatNameId = (name: unknown, id: unknown): string => {
const nameValue = formatDetailValue(name);
const idValue = formatDetailValue(id);
if (nameValue && idValue) {
return `${nameValue} / ${idValue}`;
}
return nameValue || idValue;
};
const detailRows = (log: log_line): DetailRow[] =>
compactRows([
{ label: 'File', value: log.source?.file, icon: 'i-lucide-file' },
{ label: 'Line', value: log.source?.line, icon: 'i-lucide-hash' },
{ label: 'Function', value: log.source?.function, icon: 'i-lucide-code-2' },
{ label: 'Module', value: log.source?.module, icon: 'i-lucide-box' },
{ label: 'Path', value: log.source?.path, icon: 'i-lucide-folder-tree' },
{
label: 'Process / ID',
value: formatNameId(log.process?.name, log.process?.id),
icon: 'i-lucide-cpu',
},
{
label: 'Thread / ID',
value: formatNameId(log.thread?.name, log.thread?.id),
icon: 'i-lucide-git-branch',
},
]);
const fieldRows = (log: log_line): DetailRow[] =>
compactRows(
Object.entries(log.fields ?? {}).map(([label, value]) => ({
label,
value,
icon: 'i-lucide-tag',
})),
);
const copyLogMessage = (log: log_line): void => {
copyText(log.message);
};
const copyLogRaw = (log: log_line): void => {
copyText(logRaw(log));
};
onMounted(async () => { onMounted(async () => {
if (!config.app.file_logging) { if (!config.app.file_logging) {
await navigateTo('/'); await navigateTo('/');

View file

@ -1,7 +1,35 @@
type log_line = { type log_source = {
id: number; path?: string;
line: string; file?: string;
datetime?: string; module?: string;
function?: string;
line?: number;
}; };
export type { log_line }; type log_process = {
id?: number | string;
name?: string;
};
type log_thread = {
id?: number | string;
name?: string;
};
type log_line = {
id: string;
message: string;
datetime: string;
level: string;
levelno?: number;
logger: string;
source?: log_source;
process?: log_process;
thread?: log_thread;
fields?: Record<string, unknown>;
exception?: string | null;
exception_message?: string | null;
stack?: string | null;
};
export type { log_line, log_process, log_source, log_thread };

View file

@ -168,7 +168,7 @@ const NavItems: Array<NavDefinition> = [
group: 'tools', group: 'tools',
label: 'Logs', label: 'Logs',
pageLabel: 'Logs', pageLabel: 'Logs',
description: 'Scroll near the top to load older logs.', description: 'Scroll up to load older logs.',
icon: 'i-lucide-file-text', icon: 'i-lucide-file-text',
to: '/logs', to: '/logs',
matchPath: '/logs', matchPath: '/logs',