diff --git a/API.md b/API.md index b30b3d12..9ed301c7 100644 --- a/API.md +++ b/API.md @@ -2325,12 +2325,22 @@ Binary image data with appropriate headers { "logs": [ { - "timestamp": "2023-01-01T12:00:00Z", - "level": "INFO", - "message": "...", - ... - }, - ... + "id": "", + "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": "", - "line": "", - "datetime": "2024-01-01T12:00:00.000000+00:00" + "id": "", + "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": {} } ``` diff --git a/app/features/ytdlp/extractor.py b/app/features/ytdlp/extractor.py index dea1d6d2..b8071879 100644 --- a/app/features/ytdlp/extractor.py +++ b/app/features/ytdlp/extractor.py @@ -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 diff --git a/app/features/ytdlp/tests/test_ytdlp_extractor.py b/app/features/ytdlp/tests/test_ytdlp_extractor.py index 7956ffb5..a2a7d955 100644 --- a/app/features/ytdlp/tests/test_ytdlp_extractor.py +++ b/app/features/ytdlp/tests/test_ytdlp_extractor.py @@ -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) diff --git a/app/features/ytdlp/tests/test_ytdlp_utils.py b/app/features/ytdlp/tests/test_ytdlp_utils.py index cf84e5fc..f47c617c 100644 --- a/app/features/ytdlp/tests/test_ytdlp_utils.py +++ b/app/features/ytdlp/tests/test_ytdlp_utils.py @@ -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 diff --git a/app/features/ytdlp/utils.py b/app/features/ytdlp/utils.py index a1ded5db..c3932c65 100644 --- a/app/features/ytdlp/utils.py +++ b/app/features/ytdlp/utils.py @@ -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) diff --git a/app/library/Utils.py b/app/library/Utils.py index 9db08df6..bcc79005 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -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. diff --git a/app/library/config.py b/app/library/config.py index b53e70fd..f3d8b4f4 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -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) diff --git a/app/routes/api/logs.py b/app/routes/api/logs.py index d36ed350..2a9efe54 100644 --- a/app/routes/api/logs.py +++ b/app/routes/api/logs.py @@ -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."}, diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index ff65a372..bf618da1 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -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.""" diff --git a/ui/app/pages/logs.vue b/ui/app/pages/logs.vue index fe14173d..3702f4e9 100644 --- a/ui/app/pages/logs.vue +++ b/ui/app/pages/logs.vue @@ -64,6 +64,22 @@ Wrap + + + + +
+ + Load older lines into filter + +
+ + + + + @@ -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 = { - debug: 'bg-muted', - info: 'bg-info', - warning: 'bg-warning', - error: 'bg-error', +const LOG_LEVEL_COLOR: Record = { + debug: 'neutral', + info: 'info', + warning: 'warning', + error: 'error', }; -const LOG_LEVEL_TEXT_CLASS: Record = { - debug: 'text-toned', - info: 'text-info', - warning: 'text-warning', - error: 'text-error', +const LOG_LEVEL_ICON: Record = { + 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('logContainer'); const textWrap = useStorage('logs_wrap', true); +const exceptionOpen = useStorage('logs_exception_open', false); +const fieldsOpen = useStorage('logs_fields_open', true); +const rawJsonOpen = useStorage('logs_raw_json_open', false); +const sourceOpen = useStorage('logs_source_open', true); +const selectedLevels = useStorage('logs_level_filter', [...LOG_LEVELS]); const sseController = ref(null); const logs = ref>([]); +const selectedLog = ref(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( (() => { @@ -240,12 +486,75 @@ const query = ref( 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>(() => { + const counts: Record = { + 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(() => [ + ...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(() => { 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(() => { const matchedIndexes = new Set(); 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(() => { .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 => { +const fetchLogs = async (force = false): Promise => { 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('/'); diff --git a/ui/app/types/logs.ts b/ui/app/types/logs.ts index d918adf0..48cccbda 100644 --- a/ui/app/types/logs.ts +++ b/ui/app/types/logs.ts @@ -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; + exception?: string | null; + exception_message?: string | null; + stack?: string | null; +}; + +export type { log_line, log_process, log_source, log_thread }; diff --git a/ui/app/utils/topLevelNavigation.ts b/ui/app/utils/topLevelNavigation.ts index 03c1b212..21074d4b 100644 --- a/ui/app/utils/topLevelNavigation.ts +++ b/ui/app/utils/topLevelNavigation.ts @@ -168,7 +168,7 @@ const NavItems: Array = [ 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',