Merge pull request #607 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Fix: recent change made fetch_info unpicklable
This commit is contained in:
commit
8e14258c7a
37 changed files with 1324 additions and 668 deletions
81
API.md
81
API.md
|
|
@ -59,7 +59,6 @@ This document describes the available endpoints and their usage. All endpoints r
|
||||||
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
|
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
|
||||||
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
|
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
|
||||||
- [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile)
|
- [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile)
|
||||||
- [GET /api/thumbnail](#get-apithumbnail)
|
|
||||||
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
|
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
|
||||||
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
|
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
|
||||||
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath)
|
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath)
|
||||||
|
|
@ -1655,17 +1654,6 @@ Binary TS data (`Content-Type: video/mpegts`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### GET /api/thumbnail
|
|
||||||
**Purpose**: Proxy/fetch a remote thumbnail image.
|
|
||||||
|
|
||||||
**Query Parameter**:
|
|
||||||
- `?url=<remote-thumbnail-url>`
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
Binary image data with the appropriate `Content-Type`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /api/file/ffprobe/{file:.*}
|
### GET /api/file/ffprobe/{file:.*}
|
||||||
**Purpose**: Return the `ffprobe` data for a local file.
|
**Purpose**: Return the `ffprobe` data for a local file.
|
||||||
|
|
||||||
|
|
@ -2325,12 +2313,36 @@ 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": "error",
|
||||||
...
|
"levelno": 40,
|
||||||
},
|
"logger": "downloads.queue",
|
||||||
...
|
"message": "Download failed",
|
||||||
|
"exception": {
|
||||||
|
"type": "ValueError",
|
||||||
|
"message": "bad",
|
||||||
|
"file": "/app/library/downloads/queue_manager.py",
|
||||||
|
"line": 123,
|
||||||
|
"stack": [
|
||||||
|
{
|
||||||
|
"path": "/app/library/downloads/queue_manager.py",
|
||||||
|
"file": "queue_manager.py",
|
||||||
|
"module": "queue_manager",
|
||||||
|
"function": "start",
|
||||||
|
"line": 123
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"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,
|
||||||
|
|
@ -2352,9 +2364,35 @@ 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": "error",
|
||||||
|
"levelno": 40,
|
||||||
|
"logger": "downloads.queue",
|
||||||
|
"message": "Download failed",
|
||||||
|
"exception": {
|
||||||
|
"type": "ValueError",
|
||||||
|
"message": "bad",
|
||||||
|
"file": "/app/library/downloads/queue_manager.py",
|
||||||
|
"line": 123,
|
||||||
|
"stack": [
|
||||||
|
{
|
||||||
|
"path": "/app/library/downloads/queue_manager.py",
|
||||||
|
"file": "queue_manager.py",
|
||||||
|
"module": "queue_manager",
|
||||||
|
"function": "start",
|
||||||
|
"line": 123
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"path": "/app/library/downloads/queue_manager.py",
|
||||||
|
"file": "queue_manager.py",
|
||||||
|
"module": "queue_manager",
|
||||||
|
"function": "start",
|
||||||
|
"line": 123
|
||||||
|
},
|
||||||
|
"fields": {}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -2602,7 +2640,6 @@ or an error:
|
||||||
{
|
{
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"paused": false,
|
"paused": false,
|
||||||
"live_bypasses_limits": true,
|
|
||||||
"global": {
|
"global": {
|
||||||
"limit": 20,
|
"limit": 20,
|
||||||
"active": 3,
|
"active": 3,
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,32 @@ 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:
|
return _YTDLPLogger(target)
|
||||||
|
|
||||||
|
|
||||||
|
class _YTDLPLogger:
|
||||||
|
def __init__(self, target: logging.Logger) -> None:
|
||||||
|
self.target = target
|
||||||
|
|
||||||
|
def __call__(self, 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)
|
self.target.debug(msg.removeprefix("[debug] "), *args, **kwargs)
|
||||||
return
|
return
|
||||||
|
|
||||||
if level <= logging.DEBUG:
|
if level <= logging.DEBUG:
|
||||||
target.info(msg, *args, **kwargs)
|
self.target.info(msg, *args, **kwargs)
|
||||||
return
|
return
|
||||||
|
|
||||||
target.log(level, msg, *args, **kwargs)
|
self.target.log(level, msg, *args, **kwargs)
|
||||||
|
|
||||||
return _log
|
|
||||||
|
class _LogCapture:
|
||||||
|
def __init__(self, logs: list[str]) -> None:
|
||||||
|
self.logs = logs
|
||||||
|
|
||||||
|
def __call__(self, _: int, msg: str, *args: Any, **__: Any) -> None:
|
||||||
|
self.logs.append(msg % args if args else msg)
|
||||||
|
|
||||||
|
|
||||||
def _get_process_pool_kwargs() -> dict[str, Any]:
|
def _get_process_pool_kwargs() -> dict[str, Any]:
|
||||||
|
|
@ -242,7 +256,7 @@ def extract_info_sync(
|
||||||
sanitize_info: bool = False,
|
sanitize_info: bool = False,
|
||||||
capture_logs: int | None = None,
|
capture_logs: int | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
) -> tuple[dict[str, Any] | None, list[str]]:
|
||||||
"""
|
"""
|
||||||
Extract video information from a URL.
|
Extract video information from a URL.
|
||||||
|
|
||||||
|
|
@ -257,7 +271,7 @@ def extract_info_sync(
|
||||||
**kwargs: Additional arguments
|
**kwargs: Additional arguments
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[dict | None, list[dict]]: Extracted information and captured logs.
|
tuple[dict | None, list[str]]: Extracted information and captured logs.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
params: dict[str, Any] = {**config, **_DATA.YTDLP_PARAMS, "simulate": True}
|
params: dict[str, Any] = {**config, **_DATA.YTDLP_PARAMS, "simulate": True}
|
||||||
|
|
@ -286,7 +300,7 @@ def extract_info_sync(
|
||||||
captured_logs: list[str] = kwargs.get("captured_logs", [])
|
captured_logs: list[str] = kwargs.get("captured_logs", [])
|
||||||
if capture_logs is not None:
|
if capture_logs is not None:
|
||||||
log_wrapper.add_target(
|
log_wrapper.add_target(
|
||||||
target=lambda _, msg: captured_logs.append(msg),
|
target=_LogCapture(captured_logs),
|
||||||
level=capture_logs,
|
level=capture_logs,
|
||||||
name="log-capture",
|
name="log-capture",
|
||||||
)
|
)
|
||||||
|
|
@ -350,7 +364,7 @@ async def fetch_info(
|
||||||
extractor_config: ExtractorConfig | None = None,
|
extractor_config: ExtractorConfig | None = None,
|
||||||
budget_sleep: bool = False,
|
budget_sleep: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
) -> tuple[dict[str, Any] | None, list[str]]:
|
||||||
"""
|
"""
|
||||||
Extract video information from a URL.
|
Extract video information from a URL.
|
||||||
|
|
||||||
|
|
@ -370,7 +384,7 @@ async def fetch_info(
|
||||||
**kwargs: Additional arguments
|
**kwargs: Additional arguments
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[dict | None, list[dict]]: Extracted information and captured logs.
|
tuple[dict | None, list[str]]: Extracted information and captured logs.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if extractor_config is None:
|
if extractor_config is None:
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import logging
|
import logging
|
||||||
|
import pickle
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from app.features.ytdlp.extractor import (
|
from app.features.ytdlp.extractor import (
|
||||||
ExtractorConfig,
|
ExtractorConfig,
|
||||||
ExtractorPool,
|
ExtractorPool,
|
||||||
|
_LogCapture,
|
||||||
_get_process_pool_kwargs,
|
_get_process_pool_kwargs,
|
||||||
_ytdlp_logger,
|
_ytdlp_logger,
|
||||||
extract_info_sync,
|
extract_info_sync,
|
||||||
)
|
)
|
||||||
|
from app.features.ytdlp.utils import LogWrapper
|
||||||
|
|
||||||
|
|
||||||
class TestProcessPoolConfiguration:
|
class TestProcessPoolConfiguration:
|
||||||
|
|
@ -162,11 +165,26 @@ 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)
|
||||||
|
|
||||||
|
def test_targets_are_picklable(self) -> None:
|
||||||
|
logs: list[str] = []
|
||||||
|
wrapper = LogWrapper()
|
||||||
|
wrapper.add_target(_ytdlp_logger(logging.getLogger("yt-dlp.test")), level=logging.DEBUG, name="yt-dlp.test")
|
||||||
|
wrapper.add_target(_LogCapture(logs), level=logging.WARNING, name="log-capture")
|
||||||
|
|
||||||
|
pickle.dumps(wrapper)
|
||||||
|
|
||||||
|
def test_capture_formats_args(self) -> None:
|
||||||
|
logs: list[str] = []
|
||||||
|
|
||||||
|
_LogCapture(logs)(logging.WARNING, "hello %s", "world")
|
||||||
|
|
||||||
|
assert logs == ["hello world"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ 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
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from functools import lru_cache, wraps
|
from functools import lru_cache, wraps
|
||||||
|
|
@ -40,6 +42,139 @@ 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:
|
||||||
|
source = {
|
||||||
|
"path": record.pathname,
|
||||||
|
"file": record.filename,
|
||||||
|
"module": record.module,
|
||||||
|
"function": record.funcName,
|
||||||
|
"line": record.lineno,
|
||||||
|
}
|
||||||
|
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": source,
|
||||||
|
"process": {"id": record.process, "name": record.processName},
|
||||||
|
"thread": {"id": record.thread, "name": record.threadName},
|
||||||
|
"fields": self._extra(record),
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.exc_info:
|
||||||
|
exception = self._exception(record.exc_info)
|
||||||
|
if exception is not None:
|
||||||
|
data["exception"] = exception
|
||||||
|
data["source"].update(self._exception_source(exception))
|
||||||
|
|
||||||
|
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(
|
||||||
|
exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
exc = exc_info[1]
|
||||||
|
if exc is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stack = JsonLogFormatter._exception_stack(exc_info)
|
||||||
|
data: dict[str, Any] = {"type": JsonLogFormatter._exception_type(exc)}
|
||||||
|
|
||||||
|
message = str(exc).strip()
|
||||||
|
if message:
|
||||||
|
data["message"] = message
|
||||||
|
|
||||||
|
if stack:
|
||||||
|
origin = stack[-1]
|
||||||
|
data["file"] = origin["path"]
|
||||||
|
data["line"] = origin["line"]
|
||||||
|
data["stack"] = stack
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _exception_type(exc: BaseException) -> str:
|
||||||
|
module = exc.__class__.__module__
|
||||||
|
name = exc.__class__.__qualname__
|
||||||
|
return name if module == "builtins" else f"{module}.{name}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _exception_stack(
|
||||||
|
exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
exc = exc_info[1]
|
||||||
|
tb = exc_info[2] if len(exc_info) > 2 else None
|
||||||
|
if tb is None and exc is not None:
|
||||||
|
tb = exc.__traceback__
|
||||||
|
|
||||||
|
if tb is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [JsonLogFormatter._frame(frame) for frame in traceback.extract_tb(tb)]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _frame(frame: traceback.FrameSummary) -> dict[str, Any]:
|
||||||
|
path = frame.filename
|
||||||
|
file_path = Path(path)
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
"file": file_path.name,
|
||||||
|
"module": file_path.stem,
|
||||||
|
"function": frame.name,
|
||||||
|
"line": frame.lineno,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _exception_source(exception: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
source: dict[str, Any] = {}
|
||||||
|
|
||||||
|
path = exception.get("file")
|
||||||
|
if isinstance(path, str) and path:
|
||||||
|
file_path = Path(path)
|
||||||
|
source["path"] = path
|
||||||
|
source["file"] = file_path.name
|
||||||
|
source["module"] = file_path.stem
|
||||||
|
|
||||||
|
line = exception.get("line")
|
||||||
|
if isinstance(line, int):
|
||||||
|
source["line"] = line
|
||||||
|
|
||||||
|
stack = exception.get("stack")
|
||||||
|
if isinstance(stack, list) and stack:
|
||||||
|
frame = stack[-1]
|
||||||
|
if isinstance(frame, dict):
|
||||||
|
function = frame.get("function")
|
||||||
|
if isinstance(function, str) and function:
|
||||||
|
source["function"] = function
|
||||||
|
|
||||||
|
module = frame.get("module")
|
||||||
|
if isinstance(module, str) and module:
|
||||||
|
source["module"] = module
|
||||||
|
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -450,7 +450,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
summary = ", ".join(deleted_titles[:5])
|
summary = ", ".join(deleted_titles[:5])
|
||||||
if deleted_count > 5:
|
if deleted_count > 5:
|
||||||
summary += ", ..."
|
summary += ", ..."
|
||||||
LOG.info(f"Bulk cleared {deleted_count} history item(s): {summary}")
|
LOG.info(f"Cleared '{deleted_count}' items. {summary}")
|
||||||
|
|
||||||
return {"deleted": deleted_count}
|
return {"deleted": deleted_count}
|
||||||
|
|
||||||
|
|
@ -469,7 +469,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
title="History Cleared",
|
title="History Cleared",
|
||||||
message=f"Cleared {deleted_count} item{'s' if deleted_count != 1 else ''} from history.",
|
message=f"Cleared {deleted_count} item{'s' if deleted_count != 1 else ''} from history.",
|
||||||
)
|
)
|
||||||
LOG.info(f"Bulk cleared {deleted_count} history item(s) by status '{status_filter}'.")
|
LOG.info(f"Cleared '{deleted_count}' items. Filter '{status_filter}'.")
|
||||||
return {"deleted": deleted_count}
|
return {"deleted": deleted_count}
|
||||||
|
|
||||||
items = await self.done.get_many_by_status(status_filter)
|
items = await self.done.get_many_by_status(status_filter)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse, urlsplit, urlunsplit
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import Request, Response
|
from aiohttp.web import Request, Response
|
||||||
|
|
@ -15,76 +12,31 @@ from app.library.cache import Cache
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
|
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
|
||||||
from app.library.router import route
|
from app.library.router import route
|
||||||
from app.library.Utils import validate_url
|
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
IS_REQUESTING_BACKGROUND: bool = False
|
IS_REQUESTING_BACKGROUND: bool = False
|
||||||
|
|
||||||
|
|
||||||
@route("GET", "api/thumbnail/", "get_thumbnail")
|
def _safe_url(url: str | None) -> str:
|
||||||
async def get_thumbnail(request: Request, config: Config) -> Response:
|
|
||||||
"""
|
|
||||||
Get the thumbnail.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request (Request): The request object.
|
|
||||||
config (Config): The configuration object.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object.
|
|
||||||
|
|
||||||
"""
|
|
||||||
url: str | None = request.query.get("url")
|
|
||||||
if not url:
|
if not url:
|
||||||
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code)
|
return ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
|
parsed = urlsplit(url)
|
||||||
except ValueError as e:
|
except Exception:
|
||||||
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
|
return str(url)
|
||||||
|
|
||||||
try:
|
netloc: str = parsed.netloc
|
||||||
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
|
if parsed.username or parsed.password:
|
||||||
use_curl = resolve_curl_transport()
|
host: str = parsed.hostname or ""
|
||||||
request_headers = build_request_headers(
|
if parsed.port:
|
||||||
user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
|
host: str = f"{host}:{parsed.port}"
|
||||||
use_curl=use_curl,
|
netloc: str = f"redacted:redacted@{host}" if host else "redacted:redacted"
|
||||||
)
|
|
||||||
proxy = ytdlp_args.get("proxy")
|
|
||||||
|
|
||||||
client = get_async_client(proxy=proxy, use_curl=use_curl)
|
query: str = "redacted" if parsed.query else ""
|
||||||
LOG.debug(f"Fetching thumbnail from '{url}'.")
|
fragment: str = "redacted" if parsed.fragment else ""
|
||||||
response = await client.request(
|
return urlunsplit((parsed.scheme, netloc, parsed.path, query, fragment))
|
||||||
method="GET",
|
|
||||||
url=url,
|
|
||||||
follow_redirects=True,
|
|
||||||
headers=request_headers,
|
|
||||||
timeout=10.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != web.HTTPOk.status_code:
|
|
||||||
LOG.error(f"Failed to fetch thumbnail from '{url}'. Status code: {response.status_code}.")
|
|
||||||
return web.json_response(data={"error": "failed to retrieve the thumbnail."}, status=response.status_code)
|
|
||||||
|
|
||||||
return web.Response(
|
|
||||||
body=response.content,
|
|
||||||
headers={
|
|
||||||
"Content-Type": response.headers.get("Content-Type"),
|
|
||||||
"Pragma": "public",
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
|
||||||
"Expires": time.strftime(
|
|
||||||
"%a, %d %b %Y %H:%M:%S GMT",
|
|
||||||
datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'.")
|
|
||||||
return web.json_response(
|
|
||||||
data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@route("GET", "api/random/background/", "get_background")
|
@route("GET", "api/random/background/", "get_background")
|
||||||
|
|
@ -110,7 +62,8 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
||||||
|
|
||||||
try:
|
try:
|
||||||
IS_REQUESTING_BACKGROUND = True
|
IS_REQUESTING_BACKGROUND = True
|
||||||
backend = random.choice(config.pictures_backends)
|
backend: str = random.choice(config.pictures_backends)
|
||||||
|
safe_backend: str = _safe_url(backend)
|
||||||
CACHE_KEY_BING = "random_background_bing"
|
CACHE_KEY_BING = "random_background_bing"
|
||||||
CACHE_KEY = "random_background"
|
CACHE_KEY = "random_background"
|
||||||
|
|
||||||
|
|
@ -135,12 +88,12 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
||||||
)
|
)
|
||||||
|
|
||||||
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
|
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
|
||||||
use_curl = resolve_curl_transport()
|
use_curl: bool = resolve_curl_transport()
|
||||||
request_headers = build_request_headers(
|
request_headers: dict = build_request_headers(
|
||||||
user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
|
user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
|
||||||
use_curl=use_curl,
|
use_curl=use_curl,
|
||||||
)
|
)
|
||||||
proxy = ytdlp_args.get("proxy")
|
proxy: str | None = ytdlp_args.get("proxy")
|
||||||
|
|
||||||
client = get_async_client(proxy=proxy, use_curl=use_curl)
|
client = get_async_client(proxy=proxy, use_curl=use_curl)
|
||||||
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):
|
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):
|
||||||
|
|
@ -159,10 +112,12 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
||||||
status=web.HTTPInternalServerError.status_code,
|
status=web.HTTPInternalServerError.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
backend = f"https://www.bing.com{img_url}"
|
backend: str = f"https://www.bing.com{img_url}"
|
||||||
|
safe_backend: str = _safe_url(backend)
|
||||||
await cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24)
|
await cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24)
|
||||||
else:
|
else:
|
||||||
backend = await cache.aget(CACHE_KEY_BING)
|
backend = await cache.aget(CACHE_KEY_BING)
|
||||||
|
safe_backend = _safe_url(backend if isinstance(backend, str) else None)
|
||||||
|
|
||||||
if not isinstance(backend, str) or not backend:
|
if not isinstance(backend, str) or not backend:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
|
|
@ -170,7 +125,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
||||||
status=web.HTTPInternalServerError.status_code,
|
status=web.HTTPInternalServerError.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
LOG.debug(f"Requesting random picture from '{backend!s}'.")
|
LOG.debug(f"Requesting random picture from '{safe_backend}'.")
|
||||||
|
|
||||||
response = await client.request(
|
response = await client.request(
|
||||||
method="GET",
|
method="GET",
|
||||||
|
|
@ -198,7 +153,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
||||||
|
|
||||||
await cache.aset(key=CACHE_KEY, value=data, ttl=3600)
|
await cache.aset(key=CACHE_KEY, value=data, ttl=3600)
|
||||||
|
|
||||||
LOG.debug(f"Random background image from '{backend!s}' cached.")
|
LOG.debug(f"Random background image from '{safe_backend}' cached.")
|
||||||
|
|
||||||
return web.Response(
|
return web.Response(
|
||||||
body=data.get("content"),
|
body=data.get("content"),
|
||||||
|
|
@ -210,7 +165,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Failed to request random background image from '{backend!s}'.'. '{e!s}'.")
|
LOG.error(f"Failed to request random background image from '{safe_backend}'. '{e!s}'.")
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
data={"error": "failed to retrieve the random background image."},
|
data={"error": "failed to retrieve the random background image."},
|
||||||
status=web.HTTPInternalServerError.status_code,
|
status=web.HTTPInternalServerError.status_code,
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
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."},
|
||||||
|
|
|
||||||
|
|
@ -464,7 +464,6 @@ async def system_limits(queue: DownloadQueue, config: Config, encoder: Encoder)
|
||||||
data={
|
data={
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"paused": queue.is_paused(),
|
"paused": queue.is_paused(),
|
||||||
"live_bypasses_limits": True,
|
|
||||||
"global": {
|
"global": {
|
||||||
"limit": config.max_workers,
|
"limit": config.max_workers,
|
||||||
"active": len(active_non_live),
|
"active": len(active_non_live),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
|
@ -26,28 +27,21 @@ class _Resp:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_thumb_thread(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_bg_log_redact(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
config = Config.get_instance()
|
config = Config.get_instance()
|
||||||
req = make_mocked_request("GET", "/api/thumbnail?url=https://example.com/a.jpg")
|
config.pictures_backends = ["https://user:pass@example.com/bg.jpg?apitoken=secret#frag"]
|
||||||
req._rel_url = req._rel_url.with_query({"url": "https://example.com/a.jpg"})
|
req = make_mocked_request("GET", "/api/random/background")
|
||||||
|
|
||||||
seen = {"to_thread": False, "validate": False}
|
class DummyCache:
|
||||||
|
def has(self, _key: str) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
def fake_validate_url(url: str, allow_internal: bool = False) -> bool:
|
async def aset(self, **_kwargs) -> None:
|
||||||
seen["validate"] = True
|
return None
|
||||||
assert url == "https://example.com/a.jpg"
|
|
||||||
assert allow_internal is config.allow_internal_urls
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def fake_to_thread(func, *args, **kwargs):
|
|
||||||
seen["to_thread"] = True
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
client.request.return_value = _Resp()
|
client.request.side_effect = RuntimeError("boom")
|
||||||
|
|
||||||
monkeypatch.setattr(images, "validate_url", fake_validate_url)
|
|
||||||
monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread)
|
|
||||||
monkeypatch.setattr(images, "get_async_client", lambda **_kwargs: client)
|
monkeypatch.setattr(images, "get_async_client", lambda **_kwargs: client)
|
||||||
monkeypatch.setattr(images, "resolve_curl_transport", lambda: False)
|
monkeypatch.setattr(images, "resolve_curl_transport", lambda: False)
|
||||||
monkeypatch.setattr(images, "build_request_headers", lambda **_kwargs: {})
|
monkeypatch.setattr(images, "build_request_headers", lambda **_kwargs: {})
|
||||||
|
|
@ -67,31 +61,11 @@ async def test_thumb_thread(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await images.get_thumbnail(req, config)
|
with caplog.at_level(logging.ERROR):
|
||||||
|
response = await images.get_background(req, config, DummyCache())
|
||||||
|
|
||||||
assert response.status == web.HTTPOk.status_code
|
assert response.status == web.HTTPInternalServerError.status_code
|
||||||
assert seen["to_thread"] is True
|
logs = caplog.text
|
||||||
assert seen["validate"] is True
|
assert "apitoken=secret" not in logs
|
||||||
client.request.assert_awaited_once()
|
assert "user:pass@" not in logs
|
||||||
|
assert "https://redacted:redacted@example.com/bg.jpg?redacted#redacted" in logs
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_thumb_reject(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
config = Config.get_instance()
|
|
||||||
req = make_mocked_request("GET", "/api/thumbnail?url=https://bad.example/a.jpg")
|
|
||||||
req._rel_url = req._rel_url.with_query({"url": "https://bad.example/a.jpg"})
|
|
||||||
|
|
||||||
def fake_validate_url(_url: str, allow_internal: bool = False) -> bool:
|
|
||||||
assert allow_internal is config.allow_internal_urls
|
|
||||||
raise ValueError("Invalid hostname.")
|
|
||||||
|
|
||||||
async def fake_to_thread(func, *args, **kwargs):
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(images, "validate_url", fake_validate_url)
|
|
||||||
monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread)
|
|
||||||
|
|
||||||
response = await images.get_thumbnail(req, config)
|
|
||||||
|
|
||||||
assert response.status == web.HTTPForbidden.status_code
|
|
||||||
assert response.text == '{"error": "Invalid hostname."}'
|
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,6 @@ class TestSystemLimitsEndpoint:
|
||||||
|
|
||||||
body = json.loads(response.body.decode("utf-8"))
|
body = json.loads(response.body.decode("utf-8"))
|
||||||
assert body["downloads"]["paused"] is False
|
assert body["downloads"]["paused"] is False
|
||||||
assert body["downloads"]["live_bypasses_limits"] is True
|
|
||||||
assert body["downloads"]["global"] == {
|
assert body["downloads"]["global"] == {
|
||||||
"limit": 10,
|
"limit": 10,
|
||||||
"active": 2,
|
"active": 2,
|
||||||
|
|
|
||||||
|
|
@ -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,62 @@ 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"] == {
|
||||||
|
"type": "ValueError",
|
||||||
|
"message": "bad",
|
||||||
|
"file": __file__,
|
||||||
|
"line": data["exception"]["line"],
|
||||||
|
"stack": data["exception"]["stack"],
|
||||||
|
}
|
||||||
|
assert data["exception"]["line"] > 0
|
||||||
|
assert data["exception"]["stack"][-1] == {
|
||||||
|
"path": __file__,
|
||||||
|
"file": Path(__file__).name,
|
||||||
|
"module": Path(__file__).stem,
|
||||||
|
"function": "test_exception",
|
||||||
|
"line": data["exception"]["line"],
|
||||||
|
}
|
||||||
|
assert data["source"] == data["exception"]["stack"][-1]
|
||||||
|
assert "exception_message" not in data
|
||||||
|
|
||||||
|
def test_no_raw_stack(self):
|
||||||
|
formatter = JsonLogFormatter()
|
||||||
|
record = logging.LogRecord("test", logging.ERROR, __file__, 1, "failed", (), None)
|
||||||
|
record.stack_info = 'Stack (most recent call last):\n File "x", line 1, in y'
|
||||||
|
|
||||||
|
data = json.loads(formatter.format(record))
|
||||||
|
|
||||||
|
assert "stack" not in data
|
||||||
|
|
||||||
|
|
||||||
class TestCalcDownloadPath:
|
class TestCalcDownloadPath:
|
||||||
"""Test the calc_download_path function."""
|
"""Test the calc_download_path function."""
|
||||||
|
|
||||||
|
|
@ -1524,12 +1584,84 @@ 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",
|
||||||
|
"exception": {
|
||||||
|
"type": "ValueError",
|
||||||
|
"message": "bad",
|
||||||
|
"file": "/tmp/test.py",
|
||||||
|
"line": 3,
|
||||||
|
"stack": [
|
||||||
|
{
|
||||||
|
"path": "/tmp/test.py",
|
||||||
|
"file": "test.py",
|
||||||
|
"module": "test",
|
||||||
|
"function": "run",
|
||||||
|
"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["logs"][1]["exception"]["type"] == "ValueError"
|
||||||
|
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 +1696,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."""
|
||||||
|
|
|
||||||
|
|
@ -1,201 +1,124 @@
|
||||||
<template>
|
<template>
|
||||||
<UModal
|
<UModal v-model:open="modalOpen" :title="resolvedTitle" :ui="modalUi">
|
||||||
v-if="!externalModel"
|
|
||||||
:open="true"
|
|
||||||
:title="resolvedTitle"
|
|
||||||
:dismissible="true"
|
|
||||||
:ui="{ content: 'w-full sm:max-w-5xl', body: 'p-0 overflow-hidden' }"
|
|
||||||
@update:open="(open) => !open && emitter('closeModel')"
|
|
||||||
>
|
|
||||||
<template #description>
|
<template #description>
|
||||||
<span class="sr-only">{{ modalDescription }}</span>
|
<span class="sr-only">{{ modalDescription }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex h-[75vh] min-h-96 min-w-0 flex-col">
|
<div class="space-y-4">
|
||||||
<div
|
<div v-if="isLoading" class="flex min-h-80 items-center justify-center">
|
||||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-default bg-muted/20 px-4 py-3"
|
<UIcon name="i-lucide-loader-circle" class="size-16 animate-spin text-primary" />
|
||||||
>
|
|
||||||
<div class="min-w-0 space-y-1">
|
|
||||||
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
|
||||||
<UIcon :name="contentIcon" class="size-4 text-toned" />
|
|
||||||
<span>{{ contentLabel }}</span>
|
|
||||||
<UBadge color="neutral" variant="soft" size="sm">
|
|
||||||
{{ isStructuredData ? 'JSON' : 'Text' }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
<p class="truncate text-xs text-toned">{{ sourceSummary }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UButton
|
|
||||||
type="button"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="isLoading || !hasDisplayText"
|
|
||||||
@click="wrapText = !wrapText"
|
|
||||||
>
|
|
||||||
{{ wrapText ? 'No wrap' : 'Wrap text' }}
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
type="button"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
icon="i-lucide-copy"
|
|
||||||
:disabled="isLoading || !hasDisplayText"
|
|
||||||
@click="copyData"
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 p-4 sm:p-5">
|
<UAlert
|
||||||
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center">
|
v-else-if="errorMessage"
|
||||||
<div class="flex flex-col items-center gap-3 text-center text-toned">
|
color="error"
|
||||||
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
|
variant="soft"
|
||||||
<span class="text-sm">Loading information...</span>
|
icon="i-lucide-triangle-alert"
|
||||||
</div>
|
title="Unable to load information"
|
||||||
</div>
|
:description="errorMessage"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else-if="errorMessage" class="flex h-full min-h-64 items-center justify-center">
|
<div v-else class="space-y-3">
|
||||||
<div class="w-full max-w-2xl space-y-4">
|
<UInput
|
||||||
<UAlert
|
v-model="query"
|
||||||
color="error"
|
type="search"
|
||||||
variant="soft"
|
placeholder="Filter text"
|
||||||
icon="i-lucide-triangle-alert"
|
icon="i-lucide-filter"
|
||||||
title="Unable to load information"
|
size="sm"
|
||||||
:description="errorMessage"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<UAlert
|
||||||
<UButton
|
v-if="query && 0 === filteredLineCount"
|
||||||
type="button"
|
color="warning"
|
||||||
color="neutral"
|
variant="soft"
|
||||||
variant="outline"
|
icon="i-lucide-filter"
|
||||||
size="sm"
|
title="No matching lines"
|
||||||
icon="i-lucide-rotate-ccw"
|
>
|
||||||
@click="() => void loadInfo()"
|
<template #description>
|
||||||
>
|
<p class="text-sm text-default">
|
||||||
Retry
|
No lines match this filter: <u>{{ query }}</u>
|
||||||
</UButton>
|
</p>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</UAlert>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!hasDisplayText" class="flex h-full min-h-64 items-center justify-center">
|
<UAlert
|
||||||
<UAlert
|
v-else-if="!hasDisplayText"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
icon="i-lucide-info"
|
icon="i-lucide-info"
|
||||||
title="No content returned"
|
title="No content returned"
|
||||||
description="The request completed successfully but did not return any visible content."
|
description="The request completed successfully but did not return any visible content."
|
||||||
class="w-full max-w-2xl"
|
/>
|
||||||
|
|
||||||
|
<div v-else class="overflow-hidden rounded-md border border-default bg-elevated/30">
|
||||||
|
<code
|
||||||
|
ref="contentView"
|
||||||
|
class="block max-h-[55vh] overflow-auto p-4 font-mono text-sm leading-6 text-default whitespace-pre-wrap wrap-break-word"
|
||||||
|
v-text="displayedText"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
|
||||||
|
|
||||||
<div v-else class="flex h-[75vh] min-h-96 min-w-0 flex-col">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-default bg-muted/20 px-4 py-3"
|
|
||||||
>
|
|
||||||
<div class="min-w-0 space-y-1">
|
|
||||||
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
|
||||||
<UIcon :name="contentIcon" class="size-4 text-toned" />
|
|
||||||
<span>{{ contentLabel }}</span>
|
|
||||||
<UBadge color="neutral" variant="soft" size="sm">
|
|
||||||
{{ isStructuredData ? 'JSON' : 'Text' }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
<p class="truncate text-xs text-toned">{{ sourceSummary }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UButton
|
|
||||||
type="button"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="isLoading || !hasDisplayText"
|
|
||||||
@click="wrapText = !wrapText"
|
|
||||||
>
|
|
||||||
{{ wrapText ? 'No wrap' : 'Wrap text' }}
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex w-full flex-wrap items-center justify-end gap-2">
|
||||||
<UButton
|
<UButton
|
||||||
type="button"
|
type="button"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="i-lucide-copy"
|
icon="i-lucide-copy"
|
||||||
:disabled="isLoading || !hasDisplayText"
|
:disabled="isLoading || !hasVisibleText"
|
||||||
@click="copyData"
|
@click="copyData"
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 p-4 sm:p-5">
|
<UButton
|
||||||
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center">
|
type="button"
|
||||||
<div class="flex flex-col items-center gap-3 text-center text-toned">
|
|
||||||
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
|
|
||||||
<span class="text-sm">Loading information...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="errorMessage" class="flex h-full min-h-64 items-center justify-center">
|
|
||||||
<div class="w-full max-w-2xl space-y-4">
|
|
||||||
<UAlert
|
|
||||||
color="error"
|
|
||||||
variant="soft"
|
|
||||||
icon="i-lucide-triangle-alert"
|
|
||||||
title="Unable to load information"
|
|
||||||
:description="errorMessage"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<UButton
|
|
||||||
type="button"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
icon="i-lucide-rotate-ccw"
|
|
||||||
@click="() => void loadInfo()"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!hasDisplayText" class="flex h-full min-h-64 items-center justify-center">
|
|
||||||
<UAlert
|
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="soft"
|
variant="outline"
|
||||||
icon="i-lucide-info"
|
size="sm"
|
||||||
title="No content returned"
|
icon="i-lucide-arrow-down"
|
||||||
description="The request completed successfully but did not return any visible content."
|
:disabled="isLoading || !hasVisibleText"
|
||||||
class="w-full max-w-2xl"
|
@click="scrollContent('end')"
|
||||||
/>
|
>
|
||||||
</div>
|
Go down
|
||||||
|
</UButton>
|
||||||
|
|
||||||
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre>
|
<UButton
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:disabled="!link"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click="() => void loadInfo()"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-arrow-up"
|
||||||
|
:disabled="isLoading || !hasVisibleText"
|
||||||
|
@click="scrollContent('start')"
|
||||||
|
>
|
||||||
|
Go up
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core';
|
|
||||||
|
|
||||||
const toast = useNotification();
|
const toast = useNotification();
|
||||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
||||||
|
|
||||||
|
|
@ -205,25 +128,37 @@ const props = withDefaults(
|
||||||
preset?: string;
|
preset?: string;
|
||||||
cli?: string;
|
cli?: string;
|
||||||
useUrl?: boolean;
|
useUrl?: boolean;
|
||||||
externalModel?: boolean;
|
|
||||||
code_classes?: string;
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
link: '',
|
link: '',
|
||||||
preset: '',
|
preset: '',
|
||||||
cli: '',
|
cli: '',
|
||||||
useUrl: false,
|
useUrl: false,
|
||||||
externalModel: false,
|
|
||||||
code_classes: '',
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const data = ref<unknown>(null);
|
const data = ref<unknown>(null);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const wrapText = useStorage<boolean>('get_info_wrap_text', false);
|
const query = ref('');
|
||||||
|
const contentView = ref<HTMLElement | null>(null);
|
||||||
let latestRequestId = 0;
|
let latestRequestId = 0;
|
||||||
|
|
||||||
|
const modalUi = {
|
||||||
|
content: 'w-full sm:max-w-5xl',
|
||||||
|
body: 'p-4 sm:p-5',
|
||||||
|
footer: 'px-4 pb-4 sm:px-5 sm:pb-5',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const modalOpen = computed({
|
||||||
|
get: () => true,
|
||||||
|
set: (value: boolean) => {
|
||||||
|
if (!value) {
|
||||||
|
emitter('closeModel');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const resolvedTitle = computed(() => {
|
const resolvedTitle = computed(() => {
|
||||||
if (props.useUrl && props.link.startsWith('/api/history/')) {
|
if (props.useUrl && props.link.startsWith('/api/history/')) {
|
||||||
return 'Local Information';
|
return 'Local Information';
|
||||||
|
|
@ -264,44 +199,29 @@ const displayText = computed(() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lines = computed<Array<string>>(() => {
|
||||||
|
if (!displayText.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayText.value.split('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredLines = computed<Array<string>>(() => {
|
||||||
|
if (!query.value) {
|
||||||
|
return lines.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const needle = query.value.toLowerCase();
|
||||||
|
return lines.value.filter((line) => line.toLowerCase().includes(needle));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredLineCount = computed(() => filteredLines.value.length);
|
||||||
|
const displayedText = computed(() =>
|
||||||
|
query.value ? filteredLines.value.join('\n') : displayText.value,
|
||||||
|
);
|
||||||
const hasDisplayText = computed(() => displayText.value.length > 0);
|
const hasDisplayText = computed(() => displayText.value.length > 0);
|
||||||
|
const hasVisibleText = computed(() => displayedText.value.length > 0);
|
||||||
const isStructuredData = computed(() => {
|
|
||||||
return data.value !== null && data.value !== undefined && typeof data.value !== 'string';
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentIcon = computed(() => {
|
|
||||||
return isStructuredData.value ? 'i-lucide-braces' : 'i-lucide-file-text';
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentLabel = computed(() => {
|
|
||||||
return isStructuredData.value ? 'Structured output' : 'Plain text output';
|
|
||||||
});
|
|
||||||
|
|
||||||
const sourceSummary = computed(() => {
|
|
||||||
if (props.useUrl) {
|
|
||||||
return props.link || 'Direct response source';
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = [];
|
|
||||||
|
|
||||||
if (props.preset) {
|
|
||||||
details.push(`Preset: ${props.preset}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.cli) {
|
|
||||||
details.push('Custom yt-dlp args applied');
|
|
||||||
}
|
|
||||||
|
|
||||||
return details.join(' | ') || 'yt-dlp response payload';
|
|
||||||
});
|
|
||||||
|
|
||||||
const preClasses = computed(() => [
|
|
||||||
'h-full max-h-full overflow-auto rounded-xl border border-default bg-elevated/40 p-4 text-sm text-default',
|
|
||||||
'font-mono leading-6',
|
|
||||||
wrapText.value ? 'whitespace-pre-wrap break-words' : 'whitespace-pre',
|
|
||||||
props.code_classes,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const buildRequestUrl = (): string => {
|
const buildRequestUrl = (): string => {
|
||||||
if (props.useUrl) {
|
if (props.useUrl) {
|
||||||
|
|
@ -348,6 +268,7 @@ const loadInfo = async (): Promise<void> => {
|
||||||
latestRequestId += 1;
|
latestRequestId += 1;
|
||||||
const requestId = latestRequestId;
|
const requestId = latestRequestId;
|
||||||
|
|
||||||
|
query.value = '';
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
data.value = null;
|
data.value = null;
|
||||||
|
|
@ -388,13 +309,32 @@ const loadInfo = async (): Promise<void> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyData = (): void => {
|
const copyData = (): void => {
|
||||||
if (!displayText.value) {
|
if (!displayedText.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
copyText(displayText.value);
|
copyText(displayedText.value, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scrollContent = (dir: 'start' | 'end'): void => {
|
||||||
|
if (!contentView.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentView.value.scrollTo({
|
||||||
|
top: 'start' === dir ? 0 : contentView.value.scrollHeight,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(query, () => {
|
||||||
|
if (!contentView.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentView.value.scrollTo({ top: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.link, props.preset, props.cli, props.useUrl],
|
() => [props.link, props.preset, props.cli, props.useUrl],
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -8,24 +8,40 @@ img {
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="relative min-h-[50vh]">
|
||||||
<div v-if="isLoading" class="flex min-h-[50vh] items-center justify-center">
|
<div v-if="isLoading" class="absolute inset-0 flex items-center justify-center">
|
||||||
<UIcon name="i-lucide-loader-circle" class="size-20 animate-spin text-toned sm:size-24" />
|
<UIcon name="i-lucide-loader-circle" class="size-20 animate-spin text-toned sm:size-24" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
|
||||||
<img :src="image" />
|
<div v-else-if="hasError" class="flex min-h-[50vh] items-center justify-center">
|
||||||
|
<UAlert
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
title="Unable to load image"
|
||||||
|
description="The preview could not be loaded."
|
||||||
|
class="w-full max-w-2xl"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-if="link"
|
||||||
|
:src="link"
|
||||||
|
alt="Image preview"
|
||||||
|
:class="{ invisible: isLoading || hasError }"
|
||||||
|
@load="handle_load"
|
||||||
|
@error="handle_error"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { disableOpacity, enableOpacity } from '~/utils';
|
import { disableOpacity, enableOpacity } from '~/utils';
|
||||||
|
|
||||||
const toast = useNotification();
|
|
||||||
const emitter = defineEmits(['closeModel']);
|
const emitter = defineEmits(['closeModel']);
|
||||||
|
|
||||||
const isLoading = ref<boolean>(false);
|
const isLoading = ref<boolean>(true);
|
||||||
const image = ref<string>('');
|
const hasError = ref<boolean>(false);
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
link: {
|
link: {
|
||||||
|
|
@ -41,36 +57,28 @@ const handle_event = (e: KeyboardEvent) => {
|
||||||
emitter('closeModel');
|
emitter('closeModel');
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
const handle_load = () => {
|
||||||
|
isLoading.value = false;
|
||||||
|
hasError.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle_error = () => {
|
||||||
|
isLoading.value = false;
|
||||||
|
hasError.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
disableOpacity();
|
disableOpacity();
|
||||||
document.addEventListener('keydown', handle_event);
|
document.addEventListener('keydown', handle_event);
|
||||||
|
|
||||||
const url = props.link.startsWith('/')
|
if (!props.link) {
|
||||||
? props.link
|
hasError.value = true;
|
||||||
: '/api/thumbnail?url=' + encodePath(props.link);
|
|
||||||
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
const imgRequest = await request(url);
|
|
||||||
if (200 !== imgRequest.status) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
image.value = URL.createObjectURL(await imgRequest.blob());
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error(`Error: ${e.message}`);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('keydown', handle_event);
|
document.removeEventListener('keydown', handle_event);
|
||||||
if (image.value) {
|
|
||||||
URL.revokeObjectURL(image.value);
|
|
||||||
}
|
|
||||||
enableOpacity();
|
enableOpacity();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full min-w-0 max-w-full space-y-5 p-4 sm:p-5">
|
<div class="w-full min-w-0 max-w-full space-y-5">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div class="min-w-0 space-y-2">
|
<div class="min-w-0 space-y-2">
|
||||||
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||||
<UIcon name="i-lucide-gauge" class="size-4 text-toned" />
|
<UIcon name="i-lucide-gauge" class="size-4 text-toned" />
|
||||||
<span>Queue capacity overview</span>
|
<span>Queue overview</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="max-w-3xl text-sm text-toned">
|
<p class="max-w-3xl text-sm text-toned">
|
||||||
Snapshot is based on the current queue state and configured backend limits.
|
Data is based on the current queue state and configured limits.
|
||||||
<template v-if="limits">
|
|
||||||
{{ ' ' + queueLimitDescription }}
|
|
||||||
</template>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -37,7 +34,7 @@
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<p class="text-sm font-medium text-default">Loading limits</p>
|
<p class="text-sm font-medium text-default">Loading limits</p>
|
||||||
<p class="text-xs">Fetching current queue capacity and runtime rules.</p>
|
<p class="text-xs">Fetching current queue state and runtime rules.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -166,7 +163,7 @@
|
||||||
<span>Per extractor</span>
|
<span>Per extractor</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-toned">
|
<p class="text-sm text-toned" v-if="trackedExtractorCount">
|
||||||
Extractor-specific usage and overrides currently in effect.
|
Extractor-specific usage and overrides currently in effect.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,12 +171,19 @@
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
|
||||||
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1">
|
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1">
|
||||||
<UIcon name="i-lucide-gauge" class="size-3.5 shrink-0" />
|
<UIcon name="i-lucide-gauge" class="size-3.5 shrink-0" />
|
||||||
<span>{{ limits.downloads.per_extractor.default_limit }} default per extractor</span>
|
<span
|
||||||
|
><span class="font-semibold">{{
|
||||||
|
limits.downloads.per_extractor.default_limit
|
||||||
|
}}</span
|
||||||
|
>/slots per extractor</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1">
|
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1">
|
||||||
<UIcon name="i-lucide-list" class="size-3.5 shrink-0" />
|
<UIcon name="i-lucide-list" class="size-3.5 shrink-0" />
|
||||||
<span>{{ trackedExtractorCount }} tracked</span>
|
<span
|
||||||
|
><span class="font-semibold">{{ trackedExtractorCount }}</span> tracked</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -189,8 +193,8 @@
|
||||||
color="info"
|
color="info"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
icon="i-lucide-info"
|
icon="i-lucide-info"
|
||||||
title="No extractor-specific activity"
|
title="No activity"
|
||||||
description="Overrides and extractor usage appear here once downloads have active or queued work."
|
description="Overrides and extractor usage appear here once activity is detected."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -285,14 +289,6 @@ const extractorSourceIcon = (source: string): string => {
|
||||||
return source === 'env_override' ? 'i-lucide-settings-2' : 'i-lucide-circle-check-big';
|
return source === 'env_override' ? 'i-lucide-settings-2' : 'i-lucide-circle-check-big';
|
||||||
};
|
};
|
||||||
|
|
||||||
const queueLimitDescription = computed(() => {
|
|
||||||
if (limits.value?.downloads.live_bypasses_limits === false) {
|
|
||||||
return 'Regular worker slots apply to all downloads, including live downloads.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Regular worker slots apply only to non-live downloads.';
|
|
||||||
});
|
|
||||||
|
|
||||||
const capacityRows = computed<Array<DetailRow>>(() => {
|
const capacityRows = computed<Array<DetailRow>>(() => {
|
||||||
if (!limits.value) {
|
if (!limits.value) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -304,23 +300,21 @@ const capacityRows = computed<Array<DetailRow>>(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Regular workers',
|
label: 'Regular workers',
|
||||||
description: 'Slots available for non-live downloads.',
|
description: 'Slots available.',
|
||||||
value: `${global.active}/${global.limit}`,
|
value: `${global.active}/${global.limit}`,
|
||||||
meta: `${global.available} available`,
|
meta: `${global.available} available`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Waiting queue',
|
label: 'Waiting queue',
|
||||||
description: 'Downloads waiting for a regular worker slot.',
|
description: 'Downloads waiting for worker slot.',
|
||||||
value: `${global.queued}`,
|
value: `${global.queued}`,
|
||||||
meta: global.queued === 1 ? 'item queued' : 'items queued',
|
meta: 'items queued',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Live downloads',
|
label: 'Live downloads',
|
||||||
description: downloads.live_bypasses_limits
|
description: 'Streams bypass the worker slots limits.',
|
||||||
? 'Live downloads bypass the regular worker limit.'
|
|
||||||
: 'Live downloads count toward the regular worker limit.',
|
|
||||||
value: `${global.live_active}`,
|
value: `${global.live_active}`,
|
||||||
meta: global.live_active === 1 ? 'live item active' : 'live items active',
|
meta: 'live items',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -572,7 +572,7 @@ const app_shutdown = ref<boolean>(false);
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
||||||
const show_settings = ref(false);
|
const show_settings = ref(false);
|
||||||
const checkingUpdates = ref(false);
|
const checkingUpdates = ref(false);
|
||||||
const updateCheckMessage = ref('Up to date - Click to check');
|
const updateCheckMessage = ref('Up to date - Check now');
|
||||||
const showRouteSearch = ref(false);
|
const showRouteSearch = ref(false);
|
||||||
const showSidebar = ref(false);
|
const showSidebar = ref(false);
|
||||||
const showLimits = ref(false);
|
const showLimits = ref(false);
|
||||||
|
|
@ -920,7 +920,7 @@ const checkForUpdates = async () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = 'Up to date - Click to check';
|
const msg = 'Up to date - Check now';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
checkingUpdates.value = true;
|
checkingUpdates.value = true;
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
color="neutral"
|
color="neutral"
|
||||||
:variant="show_filter ? 'soft' : 'outline'"
|
:variant="show_filter ? 'soft' : 'outline'"
|
||||||
|
|
@ -491,7 +491,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UModal
|
<UModal
|
||||||
v-if="model_item"
|
v-if="model_item && model_item.type !== 'text'"
|
||||||
:open="previewOpen"
|
:open="previewOpen"
|
||||||
:title="previewTitle"
|
:title="previewTitle"
|
||||||
:dismissible="true"
|
:dismissible="true"
|
||||||
|
|
@ -511,14 +511,6 @@
|
||||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GetInfo
|
|
||||||
v-else-if="model_item?.type === 'text'"
|
|
||||||
:link="model_item.filename"
|
|
||||||
:useUrl="true"
|
|
||||||
:externalModel="true"
|
|
||||||
@closeModel="closeModel"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
v-else-if="model_item?.type === 'image'"
|
v-else-if="model_item?.type === 'image'"
|
||||||
:link="model_item.filename"
|
:link="model_item.filename"
|
||||||
|
|
@ -526,6 +518,13 @@
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
|
<GetInfo
|
||||||
|
v-if="model_item?.type === 'text'"
|
||||||
|
:link="model_item.filename"
|
||||||
|
:useUrl="true"
|
||||||
|
@closeModel="closeModel"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="logs.length > 0"
|
v-if="logs.length > 0"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="items.length > 0"
|
v-if="items.length > 0"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 xl:justify-end">
|
<div class="flex flex-wrap justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="items.length > 0"
|
v-if="items.length > 0"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 xl:justify-end">
|
<div class="flex flex-wrap justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
v-for="entry in docsEntries"
|
v-for="entry in docsEntries"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
color="neutral"
|
color="neutral"
|
||||||
:variant="showFilter ? 'soft' : 'outline'"
|
:variant="showFilter ? 'soft' : 'outline'"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
color="neutral"
|
color="neutral"
|
||||||
:variant="toggleFilter ? 'soft' : 'outline'"
|
:variant="toggleFilter ? 'soft' : 'outline'"
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="!autoScroll"
|
v-if="!autoScroll"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
icon="i-lucide-arrow-down"
|
icon="i-lucide-arrow-down"
|
||||||
@click="scrollToBottom(false)"
|
@click="scrollToBottom(false)"
|
||||||
>
|
>
|
||||||
Scroll to bottom
|
Bottom
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
|
|
@ -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="w-44 shrink-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"
|
||||||
|
|
@ -82,7 +98,7 @@
|
||||||
<div class="overflow-hidden rounded-sm border border-default bg-default shadow-sm">
|
<div class="overflow-hidden rounded-sm border border-default bg-default shadow-sm">
|
||||||
<div
|
<div
|
||||||
ref="logContainer"
|
ref="logContainer"
|
||||||
class="logbox overflow-x-auto overflow-y-auto bg-elevated/30 font-mono text-sm text-default overscroll-x-contain"
|
class="w-full min-w-0 max-w-full min-h-[55vh] max-h-[60vh] overflow-x-auto overflow-y-auto bg-transparent font-mono text-sm text-default overscroll-x-contain"
|
||||||
@scroll.passive="handleScroll"
|
@scroll.passive="handleScroll"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -90,32 +106,80 @@
|
||||||
class="flex justify-center border-b border-default/40 px-4 py-3"
|
class="flex justify-center border-b border-default/40 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="rounded-full border border-default bg-elevated/40 px-3 py-1 text-[11px] text-toned"
|
class="inline-flex items-center gap-1.5 rounded-full border border-warning/30 bg-warning/10 px-3 py-1 text-[11px] font-medium text-warning"
|
||||||
>
|
>
|
||||||
|
<UIcon name="i-lucide-triangle-alert" class="size-3.5 shrink-0" />
|
||||||
No older lines remain in this file.
|
No older lines remain in this file.
|
||||||
</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"
|
||||||
:key="entry.log.id"
|
:key="entry.log.id"
|
||||||
:class="logRowClass(entry, index)"
|
:class="logRowClass(entry, index)"
|
||||||
>
|
>
|
||||||
<div class="log-timestamp" :title="logTimeTitle(entry.log.datetime)">
|
|
||||||
{{ logTimeLabel(entry.log.datetime) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="log-content"
|
:class="[
|
||||||
:class="textWrap ? 'log-content--wrap' : 'log-content--nowrap'"
|
'flex min-w-0 flex-1 items-start gap-[0.65rem] px-3 py-[0.65rem] leading-[1.6]',
|
||||||
|
textWrap ? 'w-full' : 'w-max min-w-full',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<span
|
<p :class="logLineClass()">
|
||||||
class="log-tone-dot"
|
<span
|
||||||
:class="`log-tone-dot--${detectLogTone(entry.log.line)}`"
|
class="inline-flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 align-middle"
|
||||||
/>
|
>
|
||||||
<p class="log-line" :class="textWrap ? 'log-line--wrap' : 'log-line--nowrap'">
|
<UTooltip :text="logTimeTitle(entry.log.datetime)">
|
||||||
{{ entry.log.line }}
|
<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"
|
||||||
|
:title="entry.log.logger"
|
||||||
|
class="inline-block max-w-[46vw] truncate align-middle text-[11px] font-semibold text-toned sm:max-w-104"
|
||||||
|
>[{{ entry.log.logger }}]</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="ml-2">{{ entry.log.message }}</span>
|
||||||
|
<span v-if="exceptionSummary(entry.log)" class="ml-1 text-error/90">
|
||||||
|
: {{ exceptionSummary(entry.log) }}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -126,21 +190,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>
|
||||||
|
|
@ -148,6 +208,176 @@
|
||||||
</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"
|
||||||
|
class="max-w-full min-w-0"
|
||||||
|
:title="selectedLog.logger"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 max-w-full truncate">{{ selectedLog.logger }}</span>
|
||||||
|
</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="exceptionSummary(selectedLog)"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-badge-alert"
|
||||||
|
:title="exceptionSummary(selectedLog)"
|
||||||
|
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"
|
||||||
|
>{{ exceptionText(selectedLog) }}</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>
|
||||||
|
|
||||||
|
|
@ -157,18 +387,44 @@ 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 = {
|
||||||
log: log_line;
|
log: log_line;
|
||||||
|
level: LogLevel;
|
||||||
isMatch: boolean;
|
isMatch: boolean;
|
||||||
isContext: boolean;
|
isContext: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LogTone = 'default' | '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_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_COLOR: Record<LogLevel, LogLevelColor> = {
|
||||||
|
debug: 'neutral',
|
||||||
|
info: 'info',
|
||||||
|
warning: 'warning',
|
||||||
|
error: '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;
|
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
|
@ -179,13 +435,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',
|
||||||
|
|
@ -193,6 +456,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>(
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -216,12 +483,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 = '';
|
||||||
|
|
@ -240,9 +570,20 @@ 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) => ({ log, isMatch: false, isContext: false }));
|
return logs.value.map((log) => ({
|
||||||
|
log,
|
||||||
|
level: getLogLevel(log.level),
|
||||||
|
isMatch: false,
|
||||||
|
isContext: false,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: Array<FilteredLogEntry> = [];
|
const result: Array<FilteredLogEntry> = [];
|
||||||
|
|
@ -250,7 +591,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 (
|
||||||
|
|
@ -266,8 +616,14 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
|
||||||
Array.from(visibleIndexes)
|
Array.from(visibleIndexes)
|
||||||
.sort((a, b) => a - b)
|
.sort((a, b) => a - b)
|
||||||
.forEach((index) => {
|
.forEach((index) => {
|
||||||
|
const log = logs.value[index] as log_line;
|
||||||
|
if (!selectedLevelSet.value.has(getLogLevel(log.level))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
log: logs.value[index] as log_line,
|
log,
|
||||||
|
level: getLogLevel(log.level),
|
||||||
isMatch: matchedIndexes.has(index),
|
isMatch: matchedIndexes.has(index),
|
||||||
isContext: !matchedIndexes.has(index),
|
isContext: !matchedIndexes.has(index),
|
||||||
});
|
});
|
||||||
|
|
@ -289,10 +645,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;
|
||||||
}
|
}
|
||||||
|
|
@ -440,49 +796,171 @@ const startLogStream = async (): Promise<void> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const logTimeLabel = (value?: string): string =>
|
const logTimeLabel = (value?: string): string =>
|
||||||
value ? moment(value).format('HH:mm:ss') : '--:--:--';
|
value ? moment(value).format('HH:mm:ss') : '00:00:00';
|
||||||
|
|
||||||
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 detectLogTone = (line: string): LogTone => {
|
const logRaw = (log: log_line): string => JSON.stringify(log, null, 2);
|
||||||
const normalized = line.toLowerCase();
|
|
||||||
|
|
||||||
if (/error|failed|exception|traceback|fatal/.test(normalized)) {
|
const exceptionSummary = (log: log_line): string => {
|
||||||
return 'error';
|
const type = log.exception?.type?.trim() ?? '';
|
||||||
|
const message = log.exception?.message?.trim() ?? '';
|
||||||
|
|
||||||
|
if (type && message) {
|
||||||
|
return `${type}: ${message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/warn|deprecated|retry/.test(normalized)) {
|
return type || message;
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/info|started|connected|listening|ready/.test(normalized)) {
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'default';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exceptionText = (log: log_line): string =>
|
||||||
|
log.exception ? JSON.stringify(log.exception, null, 2) : '';
|
||||||
|
|
||||||
|
const searchableLog = (log: log_line): string =>
|
||||||
|
[
|
||||||
|
log.message,
|
||||||
|
log.level,
|
||||||
|
log.logger,
|
||||||
|
exceptionSummary(log),
|
||||||
|
log.exception ? JSON.stringify(log.exception) : '',
|
||||||
|
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':
|
||||||
|
return 'info';
|
||||||
|
case 'warning':
|
||||||
|
case 'warn':
|
||||||
|
return 'warning';
|
||||||
|
case 'error':
|
||||||
|
case 'critical':
|
||||||
|
case 'fatal':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'debug';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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' : '',
|
||||||
|
];
|
||||||
|
|
||||||
|
const logLineClass = (): string[] => [
|
||||||
|
'flex-1',
|
||||||
|
textWrap.value
|
||||||
|
? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]'
|
||||||
|
: 'min-w-max whitespace-pre',
|
||||||
|
'text-default',
|
||||||
|
];
|
||||||
|
|
||||||
const logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
|
const logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
|
||||||
const classes = ['log-row', `log-row--${detectLogTone(entry.log.line)}`];
|
const classes = [LOG_ROW_CLASS];
|
||||||
|
|
||||||
if (entry.isMatch) {
|
if (entry.isMatch) {
|
||||||
classes.push('log-row--match');
|
classes.push('bg-warning/10');
|
||||||
return classes;
|
return classes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.isContext) {
|
if (entry.isContext) {
|
||||||
classes.push('log-row--context');
|
classes.push('bg-muted/30');
|
||||||
return classes;
|
return classes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index % 2 === 1) {
|
if (index % 2 === 1) {
|
||||||
classes.push('log-row--alt');
|
classes.push('bg-elevated/40');
|
||||||
}
|
}
|
||||||
|
|
||||||
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('/');
|
||||||
|
|
@ -502,132 +980,3 @@ onBeforeUnmount(() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.logbox {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
min-height: 55vh;
|
|
||||||
max-height: 60vh;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-row {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
border-bottom: 1px solid color-mix(in oklab, var(--ui-border) 40%, transparent);
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: transparent;
|
|
||||||
transition:
|
|
||||||
background-color 0.15s ease,
|
|
||||||
border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-row:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-row--alt {
|
|
||||||
background: color-mix(in oklab, var(--ui-bg-elevated) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-row--match {
|
|
||||||
background: color-mix(in oklab, var(--ui-warning) 10%, var(--ui-bg-elevated) 90%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-row--context {
|
|
||||||
background: color-mix(in oklab, var(--ui-bg-muted) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-row:hover {
|
|
||||||
background: color-mix(in oklab, var(--ui-bg-elevated) 70%, var(--ui-bg-muted) 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-timestamp {
|
|
||||||
display: flex;
|
|
||||||
width: 5.5rem;
|
|
||||||
min-width: 5.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
border-right: 1px solid color-mix(in oklab, var(--ui-border) 40%, transparent);
|
|
||||||
background: color-mix(in oklab, var(--ui-bg-elevated) 55%, transparent);
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ui-text-toned);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-content {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.65rem;
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-content--nowrap {
|
|
||||||
width: max-content;
|
|
||||||
min-width: calc(100% - 5.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-tone-dot {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 0.5rem;
|
|
||||||
min-width: 0.5rem;
|
|
||||||
height: 0.5rem;
|
|
||||||
margin-top: 0.45rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-tone-dot--default {
|
|
||||||
background: color-mix(in oklab, var(--ui-text-toned) 55%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-tone-dot--info {
|
|
||||||
background: var(--ui-info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-tone-dot--warning {
|
|
||||||
background: var(--ui-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-tone-dot--error {
|
|
||||||
background: var(--ui-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-line {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-line--wrap {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-line--nowrap {
|
|
||||||
min-width: max-content;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-row--default .log-line {
|
|
||||||
color: var(--ui-text-highlighted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-row--info .log-line {
|
|
||||||
color: color-mix(in oklab, var(--ui-info) 55%, var(--ui-text-highlighted) 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-row--warning .log-line {
|
|
||||||
color: color-mix(in oklab, var(--ui-warning) 60%, var(--ui-text-highlighted) 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-row--error .log-line {
|
|
||||||
color: color-mix(in oklab, var(--ui-error) 70%, var(--ui-text-highlighted) 30%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="notifications.length > 0"
|
v-if="notifications.length > 0"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
:disabled="sendingTest"
|
:disabled="sendingTest"
|
||||||
@click="() => void sendTest()"
|
@click="() => void sendTest()"
|
||||||
>
|
>
|
||||||
<span>Send Test</span>
|
<span>Test</span>
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="presets.length > 0"
|
v-if="presets.length > 0"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="definitions.length > 0"
|
v-if="definitions.length > 0"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
@ -130,8 +130,6 @@
|
||||||
</UButton>
|
</UButton>
|
||||||
</UDropdownMenu>
|
</UDropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs text-toned">{{ filteredDefinitions.length }} displayed</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="tasks.length > 0"
|
v-if="tasks.length > 0"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ export type SystemLimitsExtractor = {
|
||||||
export type SystemLimitsResponse = {
|
export type SystemLimitsResponse = {
|
||||||
downloads: {
|
downloads: {
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
live_bypasses_limits: boolean;
|
|
||||||
global: {
|
global: {
|
||||||
limit: number;
|
limit: number;
|
||||||
active: number;
|
active: number;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,49 @@
|
||||||
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_exception_frame = {
|
||||||
|
path?: string;
|
||||||
|
file?: string;
|
||||||
|
module?: string;
|
||||||
|
function?: string;
|
||||||
|
line?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type log_exception = {
|
||||||
|
type: string;
|
||||||
|
message?: string;
|
||||||
|
file?: string;
|
||||||
|
line?: number;
|
||||||
|
stack?: Array<log_exception_frame>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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?: log_exception | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { log_exception, log_exception_frame, log_line, log_process, log_source, log_thread };
|
||||||
|
|
|
||||||
|
|
@ -873,7 +873,7 @@ const getPath = (basePath: string, item: StoreItem): string => {
|
||||||
|
|
||||||
const getRemoteImage = (item: StoreItem, fallback: boolean = true): string => {
|
const getRemoteImage = (item: StoreItem, fallback: boolean = true): string => {
|
||||||
if (item?.extras?.thumbnail) {
|
if (item?.extras?.thumbnail) {
|
||||||
return uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail));
|
return uri(item.extras.thumbnail);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback ? uri('/images/placeholder.png') : '';
|
return fallback ? uri('/images/placeholder.png') : '';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import { useLocalCache } from '~/utils/cache';
|
import { useLocalCache } from '~/utils/cache';
|
||||||
|
|
||||||
const KEY = 'video:';
|
const KEY = 'video:';
|
||||||
const cache = useLocalCache();
|
let cache: ReturnType<typeof useLocalCache> | null = null;
|
||||||
|
|
||||||
|
const getCache = (): ReturnType<typeof useLocalCache> => {
|
||||||
|
if (!cache) {
|
||||||
|
cache = useLocalCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
};
|
||||||
|
|
||||||
const read = (id: string | null | undefined): number => {
|
const read = (id: string | null | undefined): number => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|
@ -9,7 +17,7 @@ const read = (id: string | null | undefined): number => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const time = Number(cache.get<number>(`${KEY}${id}`));
|
const time = Number(getCache().get<number>(`${KEY}${id}`));
|
||||||
return Number.isFinite(time) && time > 0 ? time : 0;
|
return Number.isFinite(time) && time > 0 ? time : 0;
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -21,7 +29,7 @@ const save = (id: string | null | undefined, time: number): void => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.set(`${KEY}${id}`, time, 3600 * 24);
|
getCache().set(`${KEY}${id}`, time, 3600 * 24);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clear = (id: string | null | undefined): void => {
|
const clear = (id: string | null | undefined): void => {
|
||||||
|
|
@ -29,7 +37,7 @@ const clear = (id: string | null | undefined): void => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.remove(`${KEY}${id}`);
|
getCache().remove(`${KEY}${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nearEnd = (
|
const nearEnd = (
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -12,24 +12,34 @@ const cache = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useLocalCache = mock(() => cache);
|
||||||
|
|
||||||
mock.module('~/utils/cache', () => ({
|
mock.module('~/utils/cache', () => ({
|
||||||
useLocalCache: () => cache,
|
useLocalCache,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let media: Awaited<typeof import('~/utils/media')>;
|
let media: Awaited<typeof import('~/utils/media')>;
|
||||||
|
let importCacheCalls = 0;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
media = await import('~/utils/media');
|
media = await import('~/utils/media');
|
||||||
|
importCacheCalls = useLocalCache.mock.calls.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store.clear();
|
store.clear();
|
||||||
|
useLocalCache.mockClear();
|
||||||
cache.get.mockClear();
|
cache.get.mockClear();
|
||||||
cache.set.mockClear();
|
cache.set.mockClear();
|
||||||
cache.remove.mockClear();
|
cache.remove.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('media utils', () => {
|
describe('media utils', () => {
|
||||||
|
it('defer_cache_init', () => {
|
||||||
|
expect(importCacheCalls).toBe(0);
|
||||||
|
expect(media.read('item-missing')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('clamp_seekable_range', () => {
|
it('clamp_seekable_range', () => {
|
||||||
const seekable = {
|
const seekable = {
|
||||||
length: 1,
|
length: 1,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue