feat: make log level runtime managable.
This commit is contained in:
parent
1267dbaf6b
commit
fa8ba1af34
11 changed files with 290 additions and 49 deletions
27
API.md
27
API.md
|
|
@ -88,6 +88,8 @@ This document describes the available endpoints and their usage. All endpoints r
|
||||||
- [DELETE /api/conditions/{id}](#delete-apiconditionsid)
|
- [DELETE /api/conditions/{id}](#delete-apiconditionsid)
|
||||||
- [GET /api/logs](#get-apilogs)
|
- [GET /api/logs](#get-apilogs)
|
||||||
- [GET /api/logs/stream](#get-apilogsstream)
|
- [GET /api/logs/stream](#get-apilogsstream)
|
||||||
|
- [GET /api/logs/level](#get-apilogslevel)
|
||||||
|
- [POST /api/logs/level/{level}](#post-apilogslevellevel)
|
||||||
- [GET /api/notifications/](#get-apinotifications)
|
- [GET /api/notifications/](#get-apinotifications)
|
||||||
- [GET /api/notifications/events/](#get-apinotificationsevents)
|
- [GET /api/notifications/events/](#get-apinotificationsevents)
|
||||||
- [POST /api/notifications/](#post-apinotifications)
|
- [POST /api/notifications/](#post-apinotifications)
|
||||||
|
|
@ -2421,6 +2423,31 @@ Binary image data with appropriate headers
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### GET /api/logs/level
|
||||||
|
**Purpose**: Read the active runtime log level.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conf": "info",
|
||||||
|
"active": "info",
|
||||||
|
"levels": ["debug", "info", "warning", "error"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/logs/level/{level}
|
||||||
|
**Purpose**: Change the active runtime log level.
|
||||||
|
|
||||||
|
**Path Parameter**:
|
||||||
|
- `level`: One of `debug`, `info`, `warning`, `error`.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
- `204 No Content` on success.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### GET /api/notifications/
|
### GET /api/notifications/
|
||||||
**Purpose**: Retrieve notification targets with pagination.
|
**Purpose**: Retrieve notification targets with pagination.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from pathlib import Path
|
||||||
import anyio
|
import anyio
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import Request, RequestHandler, Response
|
from aiohttp.web import Request, RequestHandler, Response
|
||||||
|
from aiohttp.web_log import AccessLogger
|
||||||
|
|
||||||
from app.library.log import get_logger
|
from app.library.log import get_logger
|
||||||
from app.library.Services import Services
|
from app.library.Services import Services
|
||||||
|
|
@ -22,6 +23,32 @@ from .Utils import decrypt_data, encrypt_data, get_file, load_modules
|
||||||
LOG = get_logger("http")
|
LOG = get_logger("http")
|
||||||
|
|
||||||
|
|
||||||
|
class HttpAccessLogger(AccessLogger):
|
||||||
|
def log(self, request: Request, response: Response, time: float) -> None:
|
||||||
|
try:
|
||||||
|
fmt_info = self._format_line(request, response, time)
|
||||||
|
|
||||||
|
values: list[object] = []
|
||||||
|
extra: dict[str, object] = {"elapsed_ms": round(time * 1000.0, 2)}
|
||||||
|
for key, value in fmt_info:
|
||||||
|
values.append(value)
|
||||||
|
|
||||||
|
if isinstance(key, str):
|
||||||
|
extra[key] = value
|
||||||
|
else:
|
||||||
|
parent, child = key
|
||||||
|
group = extra.get(parent, {})
|
||||||
|
if not isinstance(group, dict):
|
||||||
|
group = {}
|
||||||
|
group[child] = value
|
||||||
|
extra[parent] = group
|
||||||
|
|
||||||
|
message = self._log_format % tuple(values)
|
||||||
|
self.logger.info(message, extra=extra)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Error in logging")
|
||||||
|
|
||||||
|
|
||||||
class HttpAPI:
|
class HttpAPI:
|
||||||
def __init__(self, root_path: Path):
|
def __init__(self, root_path: Path):
|
||||||
self.encoder: Encoder = Encoder()
|
self.encoder: Encoder = Encoder()
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,15 @@ from .Singleton import Singleton
|
||||||
from .Utils import JsonLogFormatter
|
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
|
||||||
|
|
||||||
|
APP_THIRD_PARTY_LOG_LEVELS: tuple[tuple[str, int], ...] = (
|
||||||
|
("httpx", logging.WARNING),
|
||||||
|
("urllib3.connectionpool", logging.WARNING),
|
||||||
|
("apprise", logging.WARNING),
|
||||||
|
("httpcore", logging.INFO),
|
||||||
|
("aiosqlite", logging.INFO),
|
||||||
|
("asyncio", logging.INFO),
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from subprocess import CompletedProcess
|
from subprocess import CompletedProcess
|
||||||
|
|
||||||
|
|
@ -312,6 +321,7 @@ class Config(metaclass=Singleton):
|
||||||
_frontend_vars: tuple = (
|
_frontend_vars: tuple = (
|
||||||
"download_path",
|
"download_path",
|
||||||
"keep_archive",
|
"keep_archive",
|
||||||
|
"log_level",
|
||||||
"output_template",
|
"output_template",
|
||||||
"started",
|
"started",
|
||||||
"remove_files",
|
"remove_files",
|
||||||
|
|
@ -359,12 +369,13 @@ class Config(metaclass=Singleton):
|
||||||
|
|
||||||
def __init__(self, is_native: bool = False):
|
def __init__(self, is_native: bool = False):
|
||||||
baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute())
|
baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute())
|
||||||
|
LOG = get_logger()
|
||||||
|
|
||||||
self.config_path = os.environ.get("YTP_CONFIG_PATH", None) or str(Path(baseDefaultPath) / "var" / "config")
|
self.config_path = os.environ.get("YTP_CONFIG_PATH", None) or str(Path(baseDefaultPath) / "var" / "config")
|
||||||
envFile: str = Path(self.config_path) / ".env"
|
envFile: str = Path(self.config_path) / ".env"
|
||||||
|
|
||||||
if envFile.exists():
|
if envFile.exists():
|
||||||
logging.info(f"Loading environment variables from '{envFile}'.")
|
LOG.info("Loading environment variables from '%s'.", envFile)
|
||||||
load_dotenv(envFile)
|
load_dotenv(envFile)
|
||||||
|
|
||||||
self.is_native = is_native
|
self.is_native = is_native
|
||||||
|
|
@ -394,7 +405,7 @@ class Config(metaclass=Singleton):
|
||||||
for key in re.findall(r"\{.*?\}", v):
|
for key in re.findall(r"\{.*?\}", v):
|
||||||
localKey: str = key[1:-1]
|
localKey: str = key[1:-1]
|
||||||
if localKey not in self.__dict__:
|
if localKey not in self.__dict__:
|
||||||
logging.error(f"Config variable '{k}' had non-existing config reference '{key}'.")
|
LOG.error("Config variable '%s' had non-existing config reference '%s'.", k, key)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
v: str = v.replace(key, str(getattr(self, localKey)))
|
v: str = v.replace(key, str(getattr(self, localKey)))
|
||||||
|
|
@ -432,8 +443,6 @@ class Config(metaclass=Singleton):
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
LOG = get_logger()
|
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
try:
|
try:
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
@ -499,15 +508,7 @@ class Config(metaclass=Singleton):
|
||||||
|
|
||||||
self.started = time.time()
|
self.started = time.time()
|
||||||
|
|
||||||
_log_levels = (
|
for _tool, _level in APP_THIRD_PARTY_LOG_LEVELS:
|
||||||
("httpx", logging.WARNING),
|
|
||||||
("urllib3.connectionpool", logging.WARNING),
|
|
||||||
("apprise", logging.WARNING),
|
|
||||||
("httpcore", logging.INFO),
|
|
||||||
("aiosqlite", logging.INFO),
|
|
||||||
("asyncio", logging.INFO),
|
|
||||||
)
|
|
||||||
for _tool, _level in _log_levels:
|
|
||||||
logging.getLogger(_tool).setLevel(_level)
|
logging.getLogger(_tool).setLevel(_level)
|
||||||
|
|
||||||
if self.app_env not in ("production", "development"):
|
if self.app_env not in ("production", "development"):
|
||||||
|
|
@ -582,6 +583,9 @@ class Config(metaclass=Singleton):
|
||||||
data: dict[str, Any] = {k: getattr(self, k) for k in self._frontend_vars}
|
data: dict[str, Any] = {k: getattr(self, k) for k in self._frontend_vars}
|
||||||
|
|
||||||
data["ytdlp_version"] = Config._ytdlp_version()
|
data["ytdlp_version"] = Config._ytdlp_version()
|
||||||
|
from app.library.log_control import get_runtime_log_level
|
||||||
|
|
||||||
|
data["runtime_log_level"] = get_runtime_log_level()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_replacers(self) -> dict[str, str]:
|
def get_replacers(self) -> dict[str, str]:
|
||||||
|
|
@ -609,6 +613,7 @@ class Config(metaclass=Singleton):
|
||||||
Updates the version of the application using git tags.
|
Updates the version of the application using git tags.
|
||||||
This is used to set the version to the latest git tag.
|
This is used to set the version to the latest git tag.
|
||||||
"""
|
"""
|
||||||
|
LOG = get_logger()
|
||||||
git_path: str = Path(__file__).parent / ".." / ".." / ".git"
|
git_path: str = Path(__file__).parent / ".." / ".." / ".git"
|
||||||
if not git_path.exists():
|
if not git_path.exists():
|
||||||
return
|
return
|
||||||
|
|
@ -626,12 +631,12 @@ class Config(metaclass=Singleton):
|
||||||
)
|
)
|
||||||
|
|
||||||
if 0 != branch_result.returncode:
|
if 0 != branch_result.returncode:
|
||||||
logging.error(f"Git rev-parse failed: {branch_result.stderr.strip()}")
|
LOG.error("Git rev-parse failed: %s", branch_result.stderr.strip())
|
||||||
return
|
return
|
||||||
|
|
||||||
branch_name: str = branch_result.stdout.strip()
|
branch_name: str = branch_result.stdout.strip()
|
||||||
if not branch_name:
|
if not branch_name:
|
||||||
logging.warning("Git branch name is empty.")
|
LOG.warning("Git branch name is empty.")
|
||||||
return
|
return
|
||||||
|
|
||||||
commit_result: CompletedProcess[str] = subprocess.run(
|
commit_result: CompletedProcess[str] = subprocess.run(
|
||||||
|
|
@ -644,12 +649,12 @@ class Config(metaclass=Singleton):
|
||||||
)
|
)
|
||||||
|
|
||||||
if 0 != commit_result.returncode:
|
if 0 != commit_result.returncode:
|
||||||
logging.error(f"Git log failed: {commit_result.stderr.strip()}")
|
LOG.error("Git log failed: %s", commit_result.stderr.strip())
|
||||||
return
|
return
|
||||||
|
|
||||||
commit_info: str = commit_result.stdout.strip()
|
commit_info: str = commit_result.stdout.strip()
|
||||||
if not commit_info:
|
if not commit_info:
|
||||||
logging.warning("Git commit info is empty.")
|
LOG.warning("Git commit info is empty.")
|
||||||
return
|
return
|
||||||
|
|
||||||
commit_date, commit_sha = commit_info.split("_", 1)
|
commit_date, commit_sha = commit_info.split("_", 1)
|
||||||
|
|
@ -665,6 +670,6 @@ class Config(metaclass=Singleton):
|
||||||
"commit": self.app_commit_sha,
|
"commit": self.app_commit_sha,
|
||||||
"build_date": self.app_build_date,
|
"build_date": self.app_build_date,
|
||||||
}
|
}
|
||||||
logging.info(f"Application version info set to '{version_data}'")
|
LOG.info("Application version info set to '%s'", version_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error while getting git version: {e!s}")
|
LOG.error("Error while getting git version: %s", e)
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,9 @@ HTTP_LOGGER_NAME = "http_api"
|
||||||
LoggerKind = Literal["app", "http"]
|
LoggerKind = Literal["app", "http"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger_name(kind: LoggerKind = "app") -> str:
|
||||||
|
return HTTP_LOGGER_NAME if kind == "http" else APP_LOGGER_NAME
|
||||||
|
|
||||||
|
|
||||||
def get_logger(kind: LoggerKind = "app") -> logging.Logger:
|
def get_logger(kind: LoggerKind = "app") -> logging.Logger:
|
||||||
return logging.getLogger(HTTP_LOGGER_NAME if kind == "http" else APP_LOGGER_NAME)
|
return logging.getLogger(get_logger_name(kind))
|
||||||
|
|
|
||||||
33
app/library/log_control.py
Normal file
33
app/library/log_control.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.library.log import get_logger, get_logger_name
|
||||||
|
|
||||||
|
SUPPORTED_LOG_LEVELS: tuple[str, ...] = ("debug", "info", "warning", "error")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_log_level(level: str) -> str:
|
||||||
|
value: str = level.strip().lower()
|
||||||
|
if value not in SUPPORTED_LOG_LEVELS:
|
||||||
|
msg: str = f"Unsupported log level '{level}'."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_log_level() -> str:
|
||||||
|
return logging.getLevelName(logging.getLogger(get_logger_name()).getEffectiveLevel()).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def set_runtime_log_level(level: str) -> str:
|
||||||
|
normalized: str = normalize_log_level(level)
|
||||||
|
numeric_level: int | None = getattr(logging, normalized.upper(), None)
|
||||||
|
if not isinstance(numeric_level, int):
|
||||||
|
msg: str = f"Unsupported log level '{level}'."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
for _logger in (get_logger(),):
|
||||||
|
_logger.setLevel(numeric_level)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
@ -30,7 +30,7 @@ from app.library.cache import Cache
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
from app.library.downloads import DownloadQueue
|
from app.library.downloads import DownloadQueue
|
||||||
from app.library.Events import EventBus, Events
|
from app.library.Events import EventBus, Events
|
||||||
from app.library.HttpAPI import HttpAPI
|
from app.library.HttpAPI import HttpAccessLogger, HttpAPI
|
||||||
from app.library.HttpSocket import HttpSocket
|
from app.library.HttpSocket import HttpSocket
|
||||||
from app.library.httpx_client import close_shared_clients
|
from app.library.httpx_client import close_shared_clients
|
||||||
from app.library.log import get_logger
|
from app.library.log import get_logger
|
||||||
|
|
@ -190,12 +190,14 @@ class Main:
|
||||||
cb()
|
cb()
|
||||||
|
|
||||||
HTTP_LOGGER = None
|
HTTP_LOGGER = None
|
||||||
|
HTTP_LOGGER_CLASS = None
|
||||||
if self._config.access_log:
|
if self._config.access_log:
|
||||||
from app.library.HttpAPI import LOG as HTTP_LOGGER
|
from app.library.HttpAPI import LOG as HTTP_LOGGER
|
||||||
|
|
||||||
HTTP_LOGGER.addFilter(
|
HTTP_LOGGER.addFilter(
|
||||||
lambda record: f"GET {str(self._app.router['ping'].url_for()).rstrip('/')}" not in record.getMessage()
|
lambda record: f"GET {str(self._app.router['ping'].url_for()).rstrip('/')}" not in record.getMessage()
|
||||||
)
|
)
|
||||||
|
HTTP_LOGGER_CLASS = HttpAccessLogger
|
||||||
|
|
||||||
web.run_app(
|
web.run_app(
|
||||||
self._app,
|
self._app,
|
||||||
|
|
@ -203,6 +205,7 @@ class Main:
|
||||||
port=port,
|
port=port,
|
||||||
loop=asyncio.get_event_loop(),
|
loop=asyncio.get_event_loop(),
|
||||||
access_log=HTTP_LOGGER,
|
access_log=HTTP_LOGGER,
|
||||||
|
access_log_class=HTTP_LOGGER_CLASS,
|
||||||
print=started,
|
print=started,
|
||||||
handle_signals=cb is None,
|
handle_signals=cb is None,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ from aiohttp.web import Request, Response
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
from app.library.log import get_logger
|
from app.library.log import get_logger
|
||||||
|
from app.library.log_control import (
|
||||||
|
SUPPORTED_LOG_LEVELS,
|
||||||
|
get_runtime_log_level,
|
||||||
|
normalize_log_level,
|
||||||
|
set_runtime_log_level,
|
||||||
|
)
|
||||||
from app.library.router import route
|
from app.library.router import route
|
||||||
|
|
||||||
LOG = get_logger()
|
LOG = get_logger()
|
||||||
|
|
@ -176,6 +182,40 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@route("GET", "api/logs/level", "logs.level")
|
||||||
|
async def get_logs_level(config: Config, encoder: Encoder) -> Response:
|
||||||
|
configured = normalize_log_level(config.log_level)
|
||||||
|
active = get_runtime_log_level()
|
||||||
|
return web.json_response(
|
||||||
|
data={
|
||||||
|
"conf": configured,
|
||||||
|
"active": active,
|
||||||
|
"levels": list(SUPPORTED_LOG_LEVELS),
|
||||||
|
},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
dumps=encoder.encode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@route("POST", "api/logs/level/{level}", "logs.level.set")
|
||||||
|
async def set_logs_level(request: Request) -> Response:
|
||||||
|
if not (level := request.match_info.get("level")):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Log level is required."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
set_runtime_log_level(level)
|
||||||
|
except ValueError as e:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"{e!s} Available levels: {', '.join(SUPPORTED_LOG_LEVELS)}."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.Response(status=web.HTTPNoContent.status_code)
|
||||||
|
|
||||||
|
|
||||||
@route("GET", "api/logs/stream", "logs.stream")
|
@route("GET", "api/logs/stream", "logs.stream")
|
||||||
async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse:
|
async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse:
|
||||||
if not config.file_logging:
|
if not config.file_logging:
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,12 @@ async def test_bg_log_redact(caplog: pytest.LogCaptureFixture, monkeypatch: pyte
|
||||||
response = await images.get_background(req, config, DummyCache())
|
response = await images.get_background(req, config, DummyCache())
|
||||||
|
|
||||||
assert response.status == web.HTTPInternalServerError.status_code
|
assert response.status == web.HTTPInternalServerError.status_code
|
||||||
record = next(record for record in caplog.records if record.name == images.LOG.name)
|
record = next(
|
||||||
|
record
|
||||||
|
for record in caplog.records
|
||||||
|
if record.name == images.LOG.name
|
||||||
|
and record.getMessage().startswith("Failed to request random background image")
|
||||||
|
)
|
||||||
assert "apitoken=secret" not in record.getMessage()
|
assert "apitoken=secret" not in record.getMessage()
|
||||||
assert "user:pass@" not in record.getMessage()
|
assert "user:pass@" not in record.getMessage()
|
||||||
assert record.url == "https://redacted:redacted@example.com/bg.jpg?redacted#redacted"
|
assert record.url == "https://redacted:redacted@example.com/bg.jpg?redacted#redacted"
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ const state = reactive<ConfigState>({
|
||||||
app_env: 'production',
|
app_env: 'production',
|
||||||
simple_mode: false,
|
simple_mode: false,
|
||||||
default_pagination: 50,
|
default_pagination: 50,
|
||||||
|
log_level: '',
|
||||||
|
runtime_log_level: '',
|
||||||
check_for_updates: true,
|
check_for_updates: true,
|
||||||
new_version: '',
|
new_version: '',
|
||||||
yt_new_version: '',
|
yt_new_version: '',
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,19 @@
|
||||||
</template>
|
</template>
|
||||||
</USelect>
|
</USelect>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-sliders-horizontal"
|
||||||
|
class="shrink-0"
|
||||||
|
:loading="runtimeLogLevelLoading"
|
||||||
|
:disabled="!canApplyRuntimeLogLevel || runtimeLogLevelLoading"
|
||||||
|
@click="applyRuntimeLogLevel"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</UButton>
|
||||||
|
|
||||||
<UInput
|
<UInput
|
||||||
v-if="toggleFilter || query"
|
v-if="toggleFilter || query"
|
||||||
id="filter"
|
id="filter"
|
||||||
|
|
@ -170,7 +183,9 @@
|
||||||
{{ getLogLevel(entry.log.level) }}
|
{{ getLogLevel(entry.log.level) }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="entry.log.logger"
|
v-if="
|
||||||
|
entry.log.logger && !['ytptube', 'http_api'].includes(entry.log.logger)
|
||||||
|
"
|
||||||
:title="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"
|
class="inline-block max-w-[46vw] truncate align-middle text-[11px] font-semibold text-toned sm:max-w-104"
|
||||||
>[{{ entry.log.logger }}]</span
|
>[{{ entry.log.logger }}]</span
|
||||||
|
|
@ -196,11 +211,11 @@
|
||||||
|
|
||||||
<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">
|
||||||
{{ emptyStateTitle }}
|
{{ hasActiveFilter ? 'No logs match these filters' : 'No log lines available' }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="text-sm text-toned">
|
<p class="text-sm text-toned">
|
||||||
{{ emptyStateDescription }}
|
{{ hasActiveFilter ? 'No logs match these filters' : 'No log lines available' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -425,6 +440,10 @@ const LOG_LEVEL_ICON: Record<LogLevel, string> = {
|
||||||
warning: 'i-lucide-triangle-alert',
|
warning: 'i-lucide-triangle-alert',
|
||||||
error: 'i-lucide-circle-x',
|
error: 'i-lucide-circle-x',
|
||||||
};
|
};
|
||||||
|
const formatLogLevel = (level: LogLevel): string => level.charAt(0).toUpperCase() + level.slice(1);
|
||||||
|
const getStoredRuntimeLogLevel = (): LogLevel | null => {
|
||||||
|
return config.app.runtime_log_level ? getLogLevel(config.app.runtime_log_level) : null;
|
||||||
|
};
|
||||||
|
|
||||||
let scrollTimeout: NodeJS.Timeout | null = null;
|
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
|
@ -441,6 +460,8 @@ const rawJsonOpen = useStorage<boolean>('logs_raw_json_open', false);
|
||||||
const sourceOpen = useStorage<boolean>('logs_source_open', true);
|
const sourceOpen = useStorage<boolean>('logs_source_open', true);
|
||||||
const selectedLevels = useStorage<LogLevel[]>('logs_level_filter', [...LOG_LEVELS]);
|
const selectedLevels = useStorage<LogLevel[]>('logs_level_filter', [...LOG_LEVELS]);
|
||||||
const sseController = ref<AbortController | null>(null);
|
const sseController = ref<AbortController | null>(null);
|
||||||
|
const runtimeLogLevel = ref<LogLevel | null>(null);
|
||||||
|
const runtimeLogLevelLoading = ref(false);
|
||||||
|
|
||||||
const logs = ref<Array<log_line>>([]);
|
const logs = ref<Array<log_line>>([]);
|
||||||
const selectedLog = ref<log_line | null>(null);
|
const selectedLog = ref<log_line | null>(null);
|
||||||
|
|
@ -512,15 +533,39 @@ const levelCounts = computed<Record<LogLevel, number>>(() => {
|
||||||
|
|
||||||
return counts;
|
return counts;
|
||||||
});
|
});
|
||||||
|
|
||||||
const levelFilterItems = computed<LevelFilterItem[]>(() => [
|
const levelFilterItems = computed<LevelFilterItem[]>(() => [
|
||||||
...LOG_LEVELS.map((level) => ({
|
...LOG_LEVELS.map((level) => ({
|
||||||
label: `${level.charAt(0).toUpperCase()}${level.slice(1)} (${levelCounts.value[level]})`,
|
label: `${formatLogLevel(level)} (${levelCounts.value[level]})`,
|
||||||
value: level,
|
value: level,
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const runtimeLogLevelCandidate = computed<LogLevel | null>(() => {
|
||||||
|
for (const level of LOG_LEVELS) {
|
||||||
|
const thresholdLevels = LOG_LEVELS.slice(LOG_LEVELS.indexOf(level));
|
||||||
|
if (thresholdLevels.length !== selectedLevelSet.value.size) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thresholdLevels.every((item) => selectedLevelSet.value.has(item))) {
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canApplyRuntimeLogLevel = computed(
|
||||||
|
() =>
|
||||||
|
runtimeLogLevelCandidate.value !== null &&
|
||||||
|
runtimeLogLevel.value !== null &&
|
||||||
|
runtimeLogLevelCandidate.value !== runtimeLogLevel.value,
|
||||||
|
);
|
||||||
|
|
||||||
const levelFilterLabel = computed(() => {
|
const levelFilterLabel = computed(() => {
|
||||||
if (selectedLevelSet.value.size === LOG_LEVELS.length) {
|
if (selectedLevelSet.value.size === LOG_LEVELS.length) {
|
||||||
return `All levels (${logs.value.length})`;
|
return `All levels`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedLevelSet.value.size === 0) {
|
if (selectedLevelSet.value.size === 0) {
|
||||||
|
|
@ -529,29 +574,7 @@ const levelFilterLabel = computed(() => {
|
||||||
|
|
||||||
return LOG_LEVELS.filter((level) => selectedLevelSet.value.has(level)).join(', ');
|
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 = '';
|
||||||
|
|
@ -718,6 +741,72 @@ const scrollToBottom = async (fast = false): Promise<void> => {
|
||||||
await scrollLogContainerToBottom(fast ? 'auto' : 'smooth');
|
await scrollLogContainerToBottom(fast ? 'auto' : 'smooth');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadRuntimeLogLevel = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const response = await request('/api/logs/level');
|
||||||
|
if (!response.ok) {
|
||||||
|
runtimeLogLevel.value = getStoredRuntimeLogLevel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (typeof data.conf === 'string' && data.conf) {
|
||||||
|
config.app.log_level = getLogLevel(data.conf);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.app.log_level && getStoredRuntimeLogLevel() === null) {
|
||||||
|
runtimeLogLevel.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.active === 'string' && data.active) {
|
||||||
|
const level = getLogLevel(data.active);
|
||||||
|
runtimeLogLevel.value = level;
|
||||||
|
config.app.runtime_log_level = level;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeLogLevel.value = getStoredRuntimeLogLevel();
|
||||||
|
} catch (error) {
|
||||||
|
runtimeLogLevel.value = getStoredRuntimeLogLevel();
|
||||||
|
console.error('Failed to load runtime log level:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyRuntimeLogLevel = async (): Promise<void> => {
|
||||||
|
const normalized = runtimeLogLevelCandidate.value;
|
||||||
|
if (!normalized || runtimeLogLevel.value === null || normalized === runtimeLogLevel.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = runtimeLogLevel.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
runtimeLogLevelLoading.value = true;
|
||||||
|
const response = await request(`/api/logs/level/${normalized}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const message = await parse_api_error(data);
|
||||||
|
runtimeLogLevel.value = previous;
|
||||||
|
toast.error(`Failed to change log level. ${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeLogLevel.value = normalized;
|
||||||
|
config.app.runtime_log_level = normalized;
|
||||||
|
toast.success('log level updated until next restart.');
|
||||||
|
} catch (error: any) {
|
||||||
|
runtimeLogLevel.value = previous;
|
||||||
|
const message = error?.message || 'Unknown error';
|
||||||
|
toast.error(`Failed to change log level. ${message}`);
|
||||||
|
} finally {
|
||||||
|
runtimeLogLevelLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleStreamMessage = (event: EventSourceMessage): void => {
|
const handleStreamMessage = (event: EventSourceMessage): void => {
|
||||||
if (event.event !== 'log_lines' || !event.data) {
|
if (event.event !== 'log_lines' || !event.data) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -967,6 +1056,8 @@ onMounted(async () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtimeLogLevel.value = getStoredRuntimeLogLevel();
|
||||||
|
await loadRuntimeLogLevel();
|
||||||
await fetchLogs();
|
await fetchLogs();
|
||||||
await startLogStream();
|
await startLogStream();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
4
ui/app/types/config.d.ts
vendored
4
ui/app/types/config.d.ts
vendored
|
|
@ -43,6 +43,10 @@ type AppConfig = {
|
||||||
app_env: 'production' | 'development';
|
app_env: 'production' | 'development';
|
||||||
/** Default number of items per page for pagination */
|
/** Default number of items per page for pagination */
|
||||||
default_pagination: number;
|
default_pagination: number;
|
||||||
|
/** Configured default log level */
|
||||||
|
log_level: 'debug' | 'info' | 'warning' | 'error' | '';
|
||||||
|
/** Active runtime log level */
|
||||||
|
runtime_log_level: 'debug' | 'info' | 'warning' | 'error' | '';
|
||||||
/** Indicates if the app should check for updates */
|
/** Indicates if the app should check for updates */
|
||||||
check_for_updates: boolean;
|
check_for_updates: boolean;
|
||||||
/** New version available, empty string if none */
|
/** New version available, empty string if none */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue