refactor: minor ui cleanup
This commit is contained in:
parent
2146e51368
commit
3bd2a7bee7
23 changed files with 225 additions and 230 deletions
1
API.md
1
API.md
|
|
@ -2602,7 +2602,6 @@ or an error:
|
|||
{
|
||||
"downloads": {
|
||||
"paused": false,
|
||||
"live_bypasses_limits": true,
|
||||
"global": {
|
||||
"limit": 20,
|
||||
"active": 3,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import random
|
|||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urlsplit, urlunsplit
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
|
@ -22,6 +22,27 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
|||
IS_REQUESTING_BACKGROUND: bool = False
|
||||
|
||||
|
||||
def _safe_url(url: str | None) -> str:
|
||||
if not url:
|
||||
return ""
|
||||
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
except Exception:
|
||||
return str(url)
|
||||
|
||||
netloc: str = parsed.netloc
|
||||
if parsed.username or parsed.password:
|
||||
host: str = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host: str = f"{host}:{parsed.port}"
|
||||
netloc: str = f"redacted:redacted@{host}" if host else "redacted:redacted"
|
||||
|
||||
query: str = "redacted" if parsed.query else ""
|
||||
fragment: str = "redacted" if parsed.fragment else ""
|
||||
return urlunsplit((parsed.scheme, netloc, parsed.path, query, fragment))
|
||||
|
||||
|
||||
@route("GET", "api/thumbnail/", "get_thumbnail")
|
||||
async def get_thumbnail(request: Request, config: Config) -> Response:
|
||||
"""
|
||||
|
|
@ -52,9 +73,10 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
|
|||
use_curl=use_curl,
|
||||
)
|
||||
proxy = ytdlp_args.get("proxy")
|
||||
safe_url = _safe_url(url)
|
||||
|
||||
client = get_async_client(proxy=proxy, use_curl=use_curl)
|
||||
LOG.debug(f"Fetching thumbnail from '{url}'.")
|
||||
LOG.debug(f"Fetching thumbnail from '{safe_url}'.")
|
||||
response = await client.request(
|
||||
method="GET",
|
||||
url=url,
|
||||
|
|
@ -64,7 +86,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
|
|||
)
|
||||
|
||||
if response.status_code != web.HTTPOk.status_code:
|
||||
LOG.error(f"Failed to fetch thumbnail from '{url}'. Status code: {response.status_code}.")
|
||||
LOG.error(f"Failed to fetch thumbnail from '{safe_url}'. Status code: {response.status_code}.")
|
||||
return web.json_response(data={"error": "failed to retrieve the thumbnail."}, status=response.status_code)
|
||||
|
||||
return web.Response(
|
||||
|
|
@ -81,7 +103,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
|
|||
},
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'.")
|
||||
LOG.error(f"Error fetching thumbnail from '{safe_url}'. '{e}'.")
|
||||
return web.json_response(
|
||||
data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code
|
||||
)
|
||||
|
|
@ -110,7 +132,8 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
|||
|
||||
try:
|
||||
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 = "random_background"
|
||||
|
||||
|
|
@ -135,12 +158,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()
|
||||
use_curl = resolve_curl_transport()
|
||||
request_headers = build_request_headers(
|
||||
use_curl: bool = resolve_curl_transport()
|
||||
request_headers: dict = build_request_headers(
|
||||
user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
|
||||
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)
|
||||
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):
|
||||
|
|
@ -159,10 +182,12 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
|||
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)
|
||||
else:
|
||||
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:
|
||||
return web.json_response(
|
||||
|
|
@ -170,7 +195,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
|||
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(
|
||||
method="GET",
|
||||
|
|
@ -198,7 +223,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
|||
|
||||
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(
|
||||
body=data.get("content"),
|
||||
|
|
@ -210,7 +235,7 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
|||
},
|
||||
)
|
||||
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(
|
||||
data={"error": "failed to retrieve the random background image."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
|
|
|
|||
|
|
@ -464,7 +464,6 @@ async def system_limits(queue: DownloadQueue, config: Config, encoder: Encoder)
|
|||
data={
|
||||
"downloads": {
|
||||
"paused": queue.is_paused(),
|
||||
"live_bypasses_limits": True,
|
||||
"global": {
|
||||
"limit": config.max_workers,
|
||||
"active": len(active_non_live),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Generator
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
|
@ -95,3 +96,48 @@ async def test_thumb_reject(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
|
||||
assert response.status == web.HTTPForbidden.status_code
|
||||
assert response.text == '{"error": "Invalid hostname."}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bg_log_redact(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = Config.get_instance()
|
||||
config.pictures_backends = ["https://user:pass@example.com/bg.jpg?apitoken=secret#frag"]
|
||||
req = make_mocked_request("GET", "/api/random/background")
|
||||
|
||||
class DummyCache:
|
||||
def has(self, _key: str) -> bool:
|
||||
return False
|
||||
|
||||
async def aset(self, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(images, "get_async_client", lambda **_kwargs: client)
|
||||
monkeypatch.setattr(images, "resolve_curl_transport", lambda: False)
|
||||
monkeypatch.setattr(images, "build_request_headers", lambda **_kwargs: {})
|
||||
monkeypatch.setattr(images.Globals, "get_random_agent", staticmethod(lambda: "agent"))
|
||||
monkeypatch.setattr(
|
||||
images.YTDLPOpts,
|
||||
"get_instance",
|
||||
staticmethod(
|
||||
lambda: type(
|
||||
"Opts",
|
||||
(),
|
||||
{
|
||||
"preset": lambda self, name: self,
|
||||
"get_all": lambda self: {},
|
||||
},
|
||||
)()
|
||||
),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
response = await images.get_background(req, config, DummyCache())
|
||||
|
||||
assert response.status == web.HTTPInternalServerError.status_code
|
||||
logs = caplog.text
|
||||
assert "apitoken=secret" not in logs
|
||||
assert "user:pass@" not in logs
|
||||
assert "https://redacted:redacted@example.com/bg.jpg?redacted#redacted" in logs
|
||||
|
|
|
|||
|
|
@ -151,7 +151,6 @@ class TestSystemLimitsEndpoint:
|
|||
|
||||
body = json.loads(response.body.decode("utf-8"))
|
||||
assert body["downloads"]["paused"] is False
|
||||
assert body["downloads"]["live_bypasses_limits"] is True
|
||||
assert body["downloads"]["global"] == {
|
||||
"limit": 10,
|
||||
"active": 2,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
<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="min-w-0 space-y-2">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-gauge" class="size-4 text-toned" />
|
||||
<span>Queue capacity overview</span>
|
||||
<span>Queue overview</span>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm text-toned">
|
||||
Snapshot is based on the current queue state and configured backend limits.
|
||||
<template v-if="limits">
|
||||
{{ ' ' + queueLimitDescription }}
|
||||
</template>
|
||||
Data is based on the current queue state and configured limits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -37,7 +34,7 @@
|
|||
|
||||
<div class="space-y-1">
|
||||
<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>
|
||||
|
|
@ -166,7 +163,7 @@
|
|||
<span>Per extractor</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
<p class="text-sm text-toned" v-if="trackedExtractorCount">
|
||||
Extractor-specific usage and overrides currently in effect.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -174,12 +171,19 @@
|
|||
<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">
|
||||
<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 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" />
|
||||
<span>{{ trackedExtractorCount }} tracked</span>
|
||||
<span
|
||||
><span class="font-semibold">{{ trackedExtractorCount }}</span> tracked</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -189,8 +193,8 @@
|
|||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-info"
|
||||
title="No extractor-specific activity"
|
||||
description="Overrides and extractor usage appear here once downloads have active or queued work."
|
||||
title="No activity"
|
||||
description="Overrides and extractor usage appear here once activity is detected."
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
@ -285,14 +289,6 @@ const extractorSourceIcon = (source: string): string => {
|
|||
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>>(() => {
|
||||
if (!limits.value) {
|
||||
return [];
|
||||
|
|
@ -304,23 +300,21 @@ const capacityRows = computed<Array<DetailRow>>(() => {
|
|||
return [
|
||||
{
|
||||
label: 'Regular workers',
|
||||
description: 'Slots available for non-live downloads.',
|
||||
description: 'Slots available.',
|
||||
value: `${global.active}/${global.limit}`,
|
||||
meta: `${global.available} available`,
|
||||
},
|
||||
{
|
||||
label: 'Waiting queue',
|
||||
description: 'Downloads waiting for a regular worker slot.',
|
||||
description: 'Downloads waiting for worker slot.',
|
||||
value: `${global.queued}`,
|
||||
meta: global.queued === 1 ? 'item queued' : 'items queued',
|
||||
meta: 'items queued',
|
||||
},
|
||||
{
|
||||
label: 'Live downloads',
|
||||
description: downloads.live_bypasses_limits
|
||||
? 'Live downloads bypass the regular worker limit.'
|
||||
: 'Live downloads count toward the regular worker limit.',
|
||||
description: 'Streams bypass the worker slots limits.',
|
||||
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 show_settings = 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 showSidebar = ref(false);
|
||||
const showLimits = ref(false);
|
||||
|
|
@ -920,7 +920,7 @@ const checkForUpdates = async () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const msg = 'Up to date - Click to check';
|
||||
const msg = 'Up to date - Check now';
|
||||
|
||||
try {
|
||||
checkingUpdates.value = true;
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
</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
|
||||
color="neutral"
|
||||
:variant="show_filter ? 'soft' : 'outline'"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
</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
|
||||
v-if="logs.length > 0"
|
||||
color="neutral"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
</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
|
||||
v-if="items.length > 0"
|
||||
color="neutral"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
</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
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
</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
|
||||
v-if="items.length > 0"
|
||||
color="neutral"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
</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
|
||||
v-for="entry in docsEntries"
|
||||
:key="entry.id"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
</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
|
||||
color="neutral"
|
||||
:variant="showFilter ? 'soft' : 'outline'"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
</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
|
||||
color="neutral"
|
||||
:variant="toggleFilter ? 'soft' : 'outline'"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
</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
|
||||
v-if="!autoScroll"
|
||||
color="neutral"
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
icon="i-lucide-arrow-down"
|
||||
@click="scrollToBottom(false)"
|
||||
>
|
||||
Scroll to bottom
|
||||
Bottom
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -82,7 +82,7 @@
|
|||
<div class="overflow-hidden rounded-sm border border-default bg-default shadow-sm">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div
|
||||
|
|
@ -90,8 +90,9 @@
|
|||
class="flex justify-center border-b border-default/40 px-4 py-3"
|
||||
>
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -102,19 +103,25 @@
|
|||
:key="entry.log.id"
|
||||
:class="logRowClass(entry, index)"
|
||||
>
|
||||
<div class="log-timestamp" :title="logTimeTitle(entry.log.datetime)">
|
||||
{{ logTimeLabel(entry.log.datetime) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="log-content"
|
||||
:class="textWrap ? 'log-content--wrap' : 'log-content--nowrap'"
|
||||
:class="[
|
||||
'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
|
||||
class="log-tone-dot"
|
||||
:class="`log-tone-dot--${detectLogTone(entry.log.line)}`"
|
||||
:class="[
|
||||
'mt-[0.45rem] inline-flex size-2 shrink-0 rounded-full',
|
||||
logLevelDotClass(entry.level),
|
||||
]"
|
||||
/>
|
||||
<p class="log-line" :class="textWrap ? 'log-line--wrap' : 'log-line--nowrap'">
|
||||
<p :class="logLineClass(entry.level)">
|
||||
<span
|
||||
class="mr-2 inline text-[11px] font-semibold text-toned cursor-pointer"
|
||||
:title="logTimeTitle(entry.log.datetime)"
|
||||
>
|
||||
{{ logTimeLabel(entry.log.datetime) }}
|
||||
</span>
|
||||
{{ entry.log.line }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -162,13 +169,30 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
|
|||
|
||||
type FilteredLogEntry = {
|
||||
log: log_line;
|
||||
level: LogLevel;
|
||||
isMatch: boolean;
|
||||
isContext: boolean;
|
||||
};
|
||||
|
||||
type LogTone = 'default' | 'info' | 'warning' | 'error';
|
||||
type LogLevel = 'debug' | 'info' | 'warning' | 'error';
|
||||
|
||||
const FILTER_CONTEXT_REGEX = /context:(\d+)/;
|
||||
const LOG_LEVEL_PREFIX =
|
||||
/^\s*(?:\[(debug|info|warning|warn|error|critical|fatal)(?:[.\]])|(debug|info|warning|warn|error|critical|fatal)\b(?::|\s|-))/i;
|
||||
const LOG_ROW_CLASS =
|
||||
'flex min-w-0 border-b border-default/40 bg-transparent transition-colors duration-150 last:border-b-0 hover:bg-elevated/70';
|
||||
const LOG_LEVEL_DOT_CLASS: Record<LogLevel, string> = {
|
||||
debug: 'bg-muted',
|
||||
info: 'bg-info',
|
||||
warning: 'bg-warning',
|
||||
error: 'bg-error',
|
||||
};
|
||||
const LOG_LEVEL_TEXT_CLASS: Record<LogLevel, string> = {
|
||||
debug: 'text-toned',
|
||||
info: 'text-info',
|
||||
warning: 'text-warning',
|
||||
error: 'text-error',
|
||||
};
|
||||
|
||||
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
|
|
@ -242,7 +266,12 @@ watch(
|
|||
|
||||
const filteredLogs = computed<FilteredLogEntry[]>(() => {
|
||||
if (!hasActiveFilter.value) {
|
||||
return logs.value.map((log) => ({ log, isMatch: false, isContext: false }));
|
||||
return logs.value.map((log) => ({
|
||||
log,
|
||||
level: detectLogLevel(log.line),
|
||||
isMatch: false,
|
||||
isContext: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const result: Array<FilteredLogEntry> = [];
|
||||
|
|
@ -266,8 +295,11 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
|
|||
Array.from(visibleIndexes)
|
||||
.sort((a, b) => a - b)
|
||||
.forEach((index) => {
|
||||
const log = logs.value[index] as log_line;
|
||||
|
||||
result.push({
|
||||
log: logs.value[index] as log_line,
|
||||
log,
|
||||
level: detectLogLevel(log.line),
|
||||
isMatch: matchedIndexes.has(index),
|
||||
isContext: !matchedIndexes.has(index),
|
||||
});
|
||||
|
|
@ -440,44 +472,59 @@ const startLogStream = async (): Promise<void> => {
|
|||
};
|
||||
|
||||
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 =>
|
||||
value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp';
|
||||
|
||||
const detectLogTone = (line: string): LogTone => {
|
||||
const normalized = line.toLowerCase();
|
||||
|
||||
if (/error|failed|exception|traceback|fatal/.test(normalized)) {
|
||||
return 'error';
|
||||
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';
|
||||
}
|
||||
|
||||
if (/warn|deprecated|retry/.test(normalized)) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (/info|started|connected|listening|ready/.test(normalized)) {
|
||||
return 'info';
|
||||
}
|
||||
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const detectLogLevel = (line: string): LogLevel => {
|
||||
const match = line.match(LOG_LEVEL_PREFIX);
|
||||
const level = match?.[1] ?? match?.[2];
|
||||
|
||||
return level ? getLogLevel(level) : 'debug';
|
||||
};
|
||||
|
||||
const logLevelDotClass = (level: LogLevel): string => LOG_LEVEL_DOT_CLASS[level];
|
||||
|
||||
const logLineClass = (level: LogLevel): string[] => [
|
||||
'flex-1',
|
||||
textWrap.value
|
||||
? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]'
|
||||
: 'min-w-max whitespace-pre',
|
||||
LOG_LEVEL_TEXT_CLASS[level],
|
||||
];
|
||||
|
||||
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) {
|
||||
classes.push('log-row--match');
|
||||
classes.push('bg-warning/10');
|
||||
return classes;
|
||||
}
|
||||
|
||||
if (entry.isContext) {
|
||||
classes.push('log-row--context');
|
||||
classes.push('bg-muted/30');
|
||||
return classes;
|
||||
}
|
||||
|
||||
if (index % 2 === 1) {
|
||||
classes.push('log-row--alt');
|
||||
classes.push('bg-elevated/40');
|
||||
}
|
||||
|
||||
return classes;
|
||||
|
|
@ -502,132 +549,3 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
});
|
||||
</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 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
|
||||
v-if="notifications.length > 0"
|
||||
color="neutral"
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
:disabled="sendingTest"
|
||||
@click="() => void sendTest()"
|
||||
>
|
||||
<span>Send Test</span>
|
||||
<span>Test</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
</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
|
||||
v-if="presets.length > 0"
|
||||
color="neutral"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
</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
|
||||
v-if="definitions.length > 0"
|
||||
color="neutral"
|
||||
|
|
@ -130,8 +130,6 @@
|
|||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-toned">{{ filteredDefinitions.length }} displayed</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
</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
|
||||
v-if="tasks.length > 0"
|
||||
color="neutral"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ export type SystemLimitsExtractor = {
|
|||
export type SystemLimitsResponse = {
|
||||
downloads: {
|
||||
paused: boolean;
|
||||
live_bypasses_limits: boolean;
|
||||
global: {
|
||||
limit: number;
|
||||
active: number;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { useLocalCache } from '~/utils/cache';
|
||||
|
||||
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 => {
|
||||
if (!id) {
|
||||
|
|
@ -9,7 +17,7 @@ const read = (id: string | null | undefined): number => {
|
|||
}
|
||||
|
||||
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;
|
||||
} catch {
|
||||
return 0;
|
||||
|
|
@ -21,7 +29,7 @@ const save = (id: string | null | undefined, time: number): void => {
|
|||
return;
|
||||
}
|
||||
|
||||
cache.set(`${KEY}${id}`, time, 3600 * 24);
|
||||
getCache().set(`${KEY}${id}`, time, 3600 * 24);
|
||||
};
|
||||
|
||||
const clear = (id: string | null | undefined): void => {
|
||||
|
|
@ -29,7 +37,7 @@ const clear = (id: string | null | undefined): void => {
|
|||
return;
|
||||
}
|
||||
|
||||
cache.remove(`${KEY}${id}`);
|
||||
getCache().remove(`${KEY}${id}`);
|
||||
};
|
||||
|
||||
const nearEnd = (
|
||||
|
|
|
|||
|
|
@ -12,24 +12,34 @@ const cache = {
|
|||
}),
|
||||
};
|
||||
|
||||
const useLocalCache = mock(() => cache);
|
||||
|
||||
mock.module('~/utils/cache', () => ({
|
||||
useLocalCache: () => cache,
|
||||
useLocalCache,
|
||||
}));
|
||||
|
||||
let media: Awaited<typeof import('~/utils/media')>;
|
||||
let importCacheCalls = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
media = await import('~/utils/media');
|
||||
importCacheCalls = useLocalCache.mock.calls.length;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
useLocalCache.mockClear();
|
||||
cache.get.mockClear();
|
||||
cache.set.mockClear();
|
||||
cache.remove.mockClear();
|
||||
});
|
||||
|
||||
describe('media utils', () => {
|
||||
it('defer_cache_init', () => {
|
||||
expect(importCacheCalls).toBe(0);
|
||||
expect(media.read('item-missing')).toBe(0);
|
||||
});
|
||||
|
||||
it('clamp_seekable_range', () => {
|
||||
const seekable = {
|
||||
length: 1,
|
||||
|
|
|
|||
Loading…
Reference in a new issue