refactor: use jsonl for loggng for better log view experience
This commit is contained in:
parent
d18145f1c5
commit
854f5f4b42
12 changed files with 773 additions and 101 deletions
41
API.md
41
API.md
|
|
@ -2325,12 +2325,22 @@ Binary image data with appropriate headers
|
|||
{
|
||||
"logs": [
|
||||
{
|
||||
"timestamp": "2023-01-01T12:00:00Z",
|
||||
"level": "INFO",
|
||||
"message": "...",
|
||||
...
|
||||
},
|
||||
...
|
||||
"id": "<uuid>",
|
||||
"datetime": "2026-05-18T12:00:00.000+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": {}
|
||||
}
|
||||
],
|
||||
"offset": 0,
|
||||
"limit": 100,
|
||||
|
|
@ -2339,6 +2349,7 @@ Binary image data with appropriate headers
|
|||
}
|
||||
```
|
||||
- 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**:
|
||||
```json
|
||||
{
|
||||
"id": "<sha256>",
|
||||
"line": "<log line>",
|
||||
"datetime": "2024-01-01T12:00:00.000000+00:00"
|
||||
"id": "<uuid>",
|
||||
"datetime": "2026-05-18T12:00:00.000+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": {}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ LOG: logging.Logger = logging.getLogger("downloads.extractor")
|
|||
|
||||
def _ytdlp_logger(target: logging.Logger):
|
||||
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] "):
|
||||
target.debug(msg.removeprefix("[debug] "), *args, **kwargs)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -162,11 +162,11 @@ class TestYtdlpLogger:
|
|||
|
||||
_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:
|
||||
logger = MagicMock()
|
||||
|
||||
_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)
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ class TestLogWrapper:
|
|||
assert len(cap.records) == 1
|
||||
assert cap.records[0].levelno == logging.INFO
|
||||
assert cap.records[0].getMessage() == "hello X"
|
||||
assert cap.records[0].funcName == "test_level_filtering_and_dispatch"
|
||||
assert len(calls) == 0
|
||||
|
||||
# WARNING hits both
|
||||
|
|
|
|||
|
|
@ -131,7 +131,9 @@ class LogWrapper:
|
|||
continue
|
||||
|
||||
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:
|
||||
target.target(level, msg, *args, **kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import base64
|
|||
import copy
|
||||
import glob
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
|
@ -40,6 +41,67 @@ class FileLogFormatter(logging.Formatter):
|
|||
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):
|
||||
"""
|
||||
Decorator that applies an LRU cache with a time-to-live (TTL) to a function.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import coloredlogs
|
|||
from dotenv import load_dotenv
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -466,14 +466,14 @@ class Config(metaclass=Singleton):
|
|||
loggingPath.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = TimedRotatingFileHandler(
|
||||
filename=loggingPath / "app.log",
|
||||
filename=loggingPath / "app.jsonl",
|
||||
when="midnight",
|
||||
backupCount=3,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
handler.setLevel(log_level_file)
|
||||
formatter = FileLogFormatter("%(asctime)s [%(levelname)s.%(name)s]: %(message)s")
|
||||
formatter = JsonLogFormatter()
|
||||
handler.setFormatter(formatter)
|
||||
logging.getLogger().addHandler(handler)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
|
@ -13,8 +13,38 @@ from app.library.router import route
|
|||
|
||||
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:
|
||||
|
|
@ -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.
|
||||
|
||||
"""
|
||||
from hashlib import sha256
|
||||
|
||||
from anyio import open_file
|
||||
|
||||
if not file.exists():
|
||||
|
|
@ -68,17 +96,8 @@ async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
|
|||
next_offset = None
|
||||
end_is_reached = True
|
||||
|
||||
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
|
||||
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode()
|
||||
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,
|
||||
}
|
||||
)
|
||||
selected = lines[-(offset + limit) : -offset] if offset else lines[-limit:]
|
||||
result.extend(log for line in selected if (log := _parse_jsonl_line(line)))
|
||||
|
||||
return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
|
||||
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.
|
||||
|
||||
"""
|
||||
from hashlib import sha256
|
||||
|
||||
from anyio import open_file
|
||||
|
||||
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)
|
||||
continue
|
||||
|
||||
msg: str = line.decode(errors="replace")
|
||||
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
if log := _parse_jsonl_line(line):
|
||||
await emitter(log)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
|
||||
return
|
||||
|
|
@ -149,7 +158,7 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
|
|||
limit = 50
|
||||
|
||||
logs_data = await _read_logfile(
|
||||
file=Path(config.config_path) / "logs" / "app.log",
|
||||
file=Path(config.config_path) / "logs" / "app.jsonl",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
|
@ -175,7 +184,7 @@ async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Res
|
|||
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():
|
||||
return web.json_response(
|
||||
data={"error": "Log file is not available."},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import asyncio
|
||||
import copy
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
|
@ -14,6 +17,7 @@ import pytest
|
|||
from app.features.ytdlp.utils import arg_converter
|
||||
from app.library.Utils import (
|
||||
FileLogFormatter,
|
||||
JsonLogFormatter,
|
||||
calc_download_path,
|
||||
check_id,
|
||||
clean_item,
|
||||
|
|
@ -278,6 +282,38 @@ class TestFileLogFormatter:
|
|||
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:
|
||||
"""Test the calc_download_path function."""
|
||||
|
||||
|
|
@ -1524,12 +1560,68 @@ class TestReadLogfile:
|
|||
|
||||
def test_read_logfile_with_content(self):
|
||||
"""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():
|
||||
result = await _read_logfile(self.log_file, limit=2)
|
||||
assert isinstance(result, dict)
|
||||
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())
|
||||
|
||||
|
|
@ -1564,6 +1656,40 @@ class TestTailLog:
|
|||
|
||||
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:
|
||||
"""Test the load_cookies function."""
|
||||
|
|
|
|||
|
|
@ -64,6 +64,22 @@
|
|||
Wrap
|
||||
</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
|
||||
v-if="toggleFilter || query"
|
||||
id="filter"
|
||||
|
|
@ -97,6 +113,22 @@
|
|||
</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">
|
||||
<article
|
||||
v-for="(entry, index) in filteredLogs"
|
||||
|
|
@ -109,20 +141,42 @@
|
|||
textWrap ? 'w-full' : 'w-max min-w-full',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'mt-[0.45rem] inline-flex size-2 shrink-0 rounded-full',
|
||||
logLevelDotClass(entry.level),
|
||||
]"
|
||||
/>
|
||||
<p :class="logLineClass(entry.level)">
|
||||
<span
|
||||
class="mr-2 inline text-[11px] font-semibold text-toned cursor-pointer"
|
||||
:title="logTimeTitle(entry.log.datetime)"
|
||||
>
|
||||
{{ logTimeLabel(entry.log.datetime) }}
|
||||
<p :class="logLineClass()">
|
||||
<span class="inline-flex items-center gap-2 align-middle">
|
||||
<UTooltip :text="logTimeTitle(entry.log.datetime)">
|
||||
<span class="inline text-[11px] font-semibold text-toned cursor-pointer">
|
||||
{{ logTimeLabel(entry.log.datetime) }}
|
||||
</span>
|
||||
</UTooltip>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
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>
|
||||
{{ entry.log.line }}
|
||||
</p>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-default">
|
||||
{{ query ? 'No logs match this query' : 'No log lines available' }}
|
||||
{{ emptyStateTitle }}
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
{{
|
||||
query
|
||||
? `No log lines found for the query: ${query}. Please try a different search term.`
|
||||
: 'No log lines are available yet.'
|
||||
}}
|
||||
{{ emptyStateDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -155,6 +205,182 @@
|
|||
</div>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
@ -164,7 +390,7 @@ 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, request, uri } from '~/utils';
|
||||
import { copyText, parse_api_error, request, uri } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
type FilteredLogEntry = {
|
||||
|
|
@ -175,23 +401,32 @@ type FilteredLogEntry = {
|
|||
};
|
||||
|
||||
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 LOG_LEVEL_PREFIX =
|
||||
/^\s*(?:\[(debug|info|warning|warn|error|critical|fatal)(?:[.\]])|(debug|info|warning|warn|error|critical|fatal)\b(?::|\s|-))/i;
|
||||
const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warning', 'error'];
|
||||
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';
|
||||
const LOG_LEVEL_DOT_CLASS: Record<LogLevel, string> = {
|
||||
debug: 'bg-muted',
|
||||
info: 'bg-info',
|
||||
warning: 'bg-warning',
|
||||
error: 'bg-error',
|
||||
const LOG_LEVEL_COLOR: Record<LogLevel, LogLevelColor> = {
|
||||
debug: 'neutral',
|
||||
info: 'info',
|
||||
warning: 'warning',
|
||||
error: 'error',
|
||||
};
|
||||
const LOG_LEVEL_TEXT_CLASS: Record<LogLevel, string> = {
|
||||
debug: 'text-toned',
|
||||
info: 'text-info',
|
||||
warning: 'text-warning',
|
||||
error: 'text-error',
|
||||
const LOG_LEVEL_ICON: Record<LogLevel, string> = {
|
||||
debug: 'i-lucide-terminal',
|
||||
info: 'i-lucide-info',
|
||||
warning: 'i-lucide-triangle-alert',
|
||||
error: 'i-lucide-circle-x',
|
||||
};
|
||||
|
||||
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||
|
|
@ -203,13 +438,20 @@ const pageShell = requirePageShell('logs');
|
|||
|
||||
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
|
||||
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 logs = ref<Array<log_line>>([]);
|
||||
const selectedLog = ref<log_line | null>(null);
|
||||
const offset = ref(0);
|
||||
const loading = ref(false);
|
||||
const autoScroll = ref(true);
|
||||
const reachedEnd = ref(false);
|
||||
const detailsOpen = ref(false);
|
||||
|
||||
const pageCardUi = {
|
||||
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',
|
||||
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>(
|
||||
(() => {
|
||||
|
|
@ -240,12 +486,75 @@ const query = ref<string>(
|
|||
const toggleFilter = ref(Boolean(query.value));
|
||||
|
||||
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 match = normalizedQuery.value.match(FILTER_CONTEXT_REGEX);
|
||||
return match ? parseInt(match[1] ?? '0', 10) : 0;
|
||||
});
|
||||
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, () => {
|
||||
if (!toggleFilter.value) {
|
||||
query.value = '';
|
||||
|
|
@ -264,11 +573,17 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
watch(detailsOpen, (open) => {
|
||||
if (!open) {
|
||||
selectedLog.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredLogs = computed<FilteredLogEntry[]>(() => {
|
||||
if (!hasActiveFilter.value) {
|
||||
return logs.value.map((log) => ({
|
||||
log,
|
||||
level: detectLogLevel(log.line),
|
||||
level: getLogLevel(log.level),
|
||||
isMatch: false,
|
||||
isContext: false,
|
||||
}));
|
||||
|
|
@ -279,7 +594,16 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
|
|||
const matchedIndexes = new Set<number>();
|
||||
|
||||
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);
|
||||
|
||||
for (
|
||||
|
|
@ -296,10 +620,13 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
|
|||
.sort((a, b) => a - b)
|
||||
.forEach((index) => {
|
||||
const log = logs.value[index] as log_line;
|
||||
if (!selectedLevelSet.value.has(getLogLevel(log.level))) {
|
||||
return;
|
||||
}
|
||||
|
||||
result.push({
|
||||
log,
|
||||
level: detectLogLevel(log.line),
|
||||
level: getLogLevel(log.level),
|
||||
isMatch: 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;
|
||||
|
||||
if (reachedEnd.value || (hasActiveFilter.value && logs.value.length > 0)) {
|
||||
if (reachedEnd.value || (!force && hasActiveFilter.value && logs.value.length > 0)) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
|
@ -477,6 +804,24 @@ const logTimeLabel = (value?: string): string =>
|
|||
const logTimeTitle = (value?: string): string =>
|
||||
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 => {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'info':
|
||||
|
|
@ -493,21 +838,20 @@ const getLogLevel = (level: string): LogLevel => {
|
|||
}
|
||||
};
|
||||
|
||||
const detectLogLevel = (line: string): LogLevel => {
|
||||
const match = line.match(LOG_LEVEL_PREFIX);
|
||||
const level = match?.[1] ?? match?.[2];
|
||||
const logLevelBadgeClass = (level: LogLevel): string[] => [
|
||||
'inline-flex items-center gap-1.5 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide cursor-pointer',
|
||||
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 logLevelDotClass = (level: LogLevel): string => LOG_LEVEL_DOT_CLASS[level];
|
||||
|
||||
const logLineClass = (level: LogLevel): string[] => [
|
||||
const logLineClass = (): string[] => [
|
||||
'flex-1',
|
||||
textWrap.value
|
||||
? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]'
|
||||
: 'min-w-max whitespace-pre',
|
||||
LOG_LEVEL_TEXT_CLASS[level],
|
||||
'text-default',
|
||||
];
|
||||
|
||||
const logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
|
||||
|
|
@ -530,6 +874,82 @@ const logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
|
|||
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 () => {
|
||||
if (!config.app.file_logging) {
|
||||
await navigateTo('/');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,35 @@
|
|||
type log_line = {
|
||||
id: number;
|
||||
line: string;
|
||||
datetime?: string;
|
||||
type log_source = {
|
||||
path?: string;
|
||||
file?: 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 };
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ const NavItems: Array<NavDefinition> = [
|
|||
group: 'tools',
|
||||
label: 'Logs',
|
||||
pageLabel: 'Logs',
|
||||
description: 'Scroll near the top to load older logs.',
|
||||
description: 'Scroll up to load older logs.',
|
||||
icon: 'i-lucide-file-text',
|
||||
to: '/logs',
|
||||
matchPath: '/logs',
|
||||
|
|
|
|||
Loading…
Reference in a new issue