refactor: minor ui cleanup

This commit is contained in:
arabcoders 2026-05-17 20:21:58 +03:00
parent 2146e51368
commit 3bd2a7bee7
23 changed files with 225 additions and 230 deletions

1
API.md
View file

@ -2602,7 +2602,6 @@ or an error:
{ {
"downloads": { "downloads": {
"paused": false, "paused": false,
"live_bypasses_limits": true,
"global": { "global": {
"limit": 20, "limit": 20,
"active": 3, "active": 3,

View file

@ -4,7 +4,7 @@ import random
import time import time
from datetime import UTC, datetime 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
@ -22,6 +22,27 @@ LOG: logging.Logger = logging.getLogger(__name__)
IS_REQUESTING_BACKGROUND: bool = False 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") @route("GET", "api/thumbnail/", "get_thumbnail")
async def get_thumbnail(request: Request, config: Config) -> Response: 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, use_curl=use_curl,
) )
proxy = ytdlp_args.get("proxy") proxy = ytdlp_args.get("proxy")
safe_url = _safe_url(url)
client = get_async_client(proxy=proxy, use_curl=use_curl) 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( response = await client.request(
method="GET", method="GET",
url=url, url=url,
@ -64,7 +86,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
) )
if response.status_code != web.HTTPOk.status_code: 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.json_response(data={"error": "failed to retrieve the thumbnail."}, status=response.status_code)
return web.Response( return web.Response(
@ -81,7 +103,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
}, },
) )
except Exception as e: 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( return web.json_response(
data={"error": "failed to retrieve the thumbnail."}, status=web.HTTPInternalServerError.status_code 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: 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 +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() 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 +182,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 +195,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 +223,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 +235,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,

View file

@ -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),

View file

@ -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
@ -95,3 +96,48 @@ async def test_thumb_reject(monkeypatch: pytest.MonkeyPatch) -> None:
assert response.status == web.HTTPForbidden.status_code assert response.status == web.HTTPForbidden.status_code
assert response.text == '{"error": "Invalid hostname."}' 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

View file

@ -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,

View file

@ -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',
}, },
]; ];
}); });

View file

@ -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;

View file

@ -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'"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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'"

View file

@ -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'"

View file

@ -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
@ -82,7 +82,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,8 +90,9 @@
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>
@ -102,19 +103,25 @@
: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 <span
class="log-tone-dot" :class="[
:class="`log-tone-dot--${detectLogTone(entry.log.line)}`" '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 }} {{ entry.log.line }}
</p> </p>
</div> </div>
@ -162,13 +169,30 @@ 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';
const FILTER_CONTEXT_REGEX = /context:(\d+)/; 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; let scrollTimeout: NodeJS.Timeout | null = null;
@ -242,7 +266,12 @@ watch(
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: detectLogLevel(log.line),
isMatch: false,
isContext: false,
}));
} }
const result: Array<FilteredLogEntry> = []; const result: Array<FilteredLogEntry> = [];
@ -266,8 +295,11 @@ 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;
result.push({ result.push({
log: logs.value[index] as log_line, log,
level: detectLogLevel(log.line),
isMatch: matchedIndexes.has(index), isMatch: matchedIndexes.has(index),
isContext: !matchedIndexes.has(index), isContext: !matchedIndexes.has(index),
}); });
@ -440,44 +472,59 @@ 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 getLogLevel = (level: string): LogLevel => {
const normalized = line.toLowerCase(); switch (level.toLowerCase()) {
case 'info':
if (/error|failed|exception|traceback|fatal/.test(normalized)) { return 'info';
return 'error'; 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 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;
@ -502,132 +549,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>

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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"

View file

@ -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;

View file

@ -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 = (

View file

@ -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,