Merge pull request #620 from arabcoders/dev
Refactor: re-design simple mode
This commit is contained in:
commit
0ba52fa38e
23 changed files with 3106 additions and 2634 deletions
25
FAQ.md
25
FAQ.md
|
|
@ -3,6 +3,9 @@
|
|||
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line,
|
||||
or the `environment:` section in `compose.yaml` file.
|
||||
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------------- | ------------------------------------------------------------------- | --------------------- |
|
||||
| TZ | The timezone to use for the application | `(not_set)` |
|
||||
|
|
@ -61,19 +64,19 @@ or the `environment:` section in `compose.yaml` file.
|
|||
| YTP_THUMB_GENERATE | Enable ffmpeg thumbnail generation when no local thumbnail exists. | `true` |
|
||||
| YTP_THUMB_SIDECAR | Save generated thumbnails next to media instead of temp cache. | `false` |
|
||||
| YTP_DISABLE_EXEC | Strip some dangerous yt-dlp options. | `false` |
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
|
||||
> The extractor name must be in uppercase, to know the extractor name, check the log for the specific extractor used for the download.
|
||||
> The limit should not exceed the `YTP_MAX_WORKERS` value as it will be ignored.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The env variable `YTP_SIMPLE_MODE` only control what being displayed for first time visitor, the users can still switch between the two modes via the WebUI settings page.
|
||||
|
||||
## Notes about YTP_AUTO_CLEAR_HISTORY_DAYS
|
||||
|
||||
- `0` days means no automatic clearing of the download history. lowest value that will trigger the clearing is `1` day.
|
||||
- This setting will **NOT** delete the downloaded files, it will only clear the history from the database.
|
||||
> To raise the worker limit for a specific extractor, set an env variable using this format: `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`
|
||||
> The extractor name must be uppercase. You can find the extractor name in the download logs. This value cannot be
|
||||
> higher than `YTP_MAX_WORKERS`; higher values are ignored.
|
||||
>
|
||||
> `YTP_SIMPLE_MODE=true` only applies when the browser has no saved layout choice yet. Users can still choose a layout in
|
||||
> WebUI Settings. `/?simple=1` forces and saves Simple for that browser.
|
||||
>
|
||||
> `YTP_AUTO_CLEAR_HISTORY_DAYS` `0` days means no automatic clearing of the download history. lowest value that will
|
||||
> trigger the clearing is `1` day. This setting will **NOT** delete the downloaded files, it will only clear the
|
||||
> history from the database.
|
||||
|
||||
# Browser extensions & bookmarklets
|
||||
|
||||
|
|
|
|||
|
|
@ -131,10 +131,9 @@ class LogWrapper:
|
|||
if level < target.level:
|
||||
continue
|
||||
|
||||
if target.logger:
|
||||
log_kwargs = {**kwargs}
|
||||
log_kwargs.setdefault("stacklevel", 3)
|
||||
if isinstance(target.target, logging.Logger):
|
||||
log_kwargs: dict[str, Any] = {**kwargs}
|
||||
log_kwargs.setdefault("stacklevel", 3)
|
||||
target.target.log(level, msg, *args, **log_kwargs)
|
||||
elif callable(target.target):
|
||||
target.target(level, msg, *args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ async def serve_static_file(request: Request, config: Config) -> StreamResponse:
|
|||
else:
|
||||
return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.FileResponse(
|
||||
response = web.FileResponse(
|
||||
path=file_path,
|
||||
headers={
|
||||
"Pragma": "public",
|
||||
|
|
@ -154,6 +154,16 @@ async def serve_static_file(request: Request, config: Config) -> StreamResponse:
|
|||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
if STATIC_STATE.index_file is not None and file_path == STATIC_STATE.index_file:
|
||||
response.set_cookie(
|
||||
name="simple_mode",
|
||||
value="true" if config.simple_mode else "false",
|
||||
path=config.base_path if "/" != config.base_path else "/",
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def setup_static_routes(root_path: Path, config: Config) -> None:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -65,6 +65,23 @@
|
|||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.ytp-terminal {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem;
|
||||
background-color: var(--ui-bg-elevated);
|
||||
color: var(--ui-text);
|
||||
line-height: 1.7;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--ui-border);
|
||||
}
|
||||
|
||||
.ytp-terminal > code {
|
||||
display: block;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
|
|
|||
159
ui/app/components/AppRoot.vue
Normal file
159
ui/app/components/AppRoot.vue
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<template>
|
||||
<UApp :toaster="toaster">
|
||||
<slot :openSettings="open" :reloadBg="loadBg" :bgLoading="bgLoading" />
|
||||
|
||||
<SettingsPanel
|
||||
:isOpen="settings"
|
||||
:isLoading="bgLoading"
|
||||
direction="right"
|
||||
@close="settings = false"
|
||||
@reload_bg="loadBg(true)"
|
||||
/>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import SettingsPanel from '~/components/SettingsPanel.vue';
|
||||
import type { toastPosition } from '~/composables/useNotification';
|
||||
import type { YTDLPOption } from '~/types/ytdlp';
|
||||
import { request, syncOpacity } from '~/utils';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode?: 'regular' | 'simple';
|
||||
loadOpts?: boolean;
|
||||
}>(),
|
||||
{
|
||||
mode: 'regular',
|
||||
loadOpts: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ ready: [] }>();
|
||||
|
||||
const cfg = useYtpConfig();
|
||||
const sock = useAppSocket();
|
||||
const bgOn = useStorage<boolean>('random_bg', true);
|
||||
const bgOpacity = useStorage<number>('random_bg_opacity', 0.95);
|
||||
const anim = useStorage<boolean>('page_anims', true);
|
||||
const toastPos = useStorage<toastPosition>('toast_position', 'top-right');
|
||||
const settings = ref(false);
|
||||
const bg = ref('');
|
||||
const bgLoading = ref(false);
|
||||
|
||||
const toaster = computed(() => ({
|
||||
position: toastPos.value,
|
||||
max: 5,
|
||||
expand: true,
|
||||
progress: true,
|
||||
}));
|
||||
|
||||
const open = (): void => {
|
||||
settings.value = true;
|
||||
};
|
||||
|
||||
const setMode = (): void => {
|
||||
document.documentElement.classList.toggle('simple-mode', props.mode === 'simple');
|
||||
};
|
||||
|
||||
const clearBg = (): void => {
|
||||
const html = document.documentElement;
|
||||
const body = document.querySelector('body');
|
||||
|
||||
html.classList.remove('bg-fanart');
|
||||
html.removeAttribute('style');
|
||||
body?.removeAttribute('style');
|
||||
bg.value = '';
|
||||
};
|
||||
|
||||
const loadBg = async (force = false): Promise<void> => {
|
||||
if (bgLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
bgLoading.value = true;
|
||||
const resp = await request(`/api/random/background${force ? '?force=true' : ''}`);
|
||||
if (resp.status === 200) {
|
||||
bg.value = URL.createObjectURL(await resp.blob());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
bgLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, loadBg });
|
||||
|
||||
const syncBg = async (): Promise<void> => {
|
||||
if (!bgOn.value) {
|
||||
clearBg();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bg.value) {
|
||||
await loadBg();
|
||||
}
|
||||
};
|
||||
|
||||
const loadOptions = async (): Promise<void> => {
|
||||
try {
|
||||
const resp = await request('/api/yt-dlp/options');
|
||||
if (resp.ok) {
|
||||
cfg.ytdlp_options = (await resp.json()) as Array<YTDLPOption>;
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
setMode();
|
||||
|
||||
try {
|
||||
await cfg.loadConfig();
|
||||
} catch {}
|
||||
|
||||
if (props.loadOpts) {
|
||||
await loadOptions();
|
||||
}
|
||||
|
||||
try {
|
||||
sock.connect();
|
||||
} catch {}
|
||||
|
||||
await syncBg();
|
||||
emit('ready');
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (props.mode === 'simple') {
|
||||
document.documentElement.classList.remove('simple-mode');
|
||||
}
|
||||
});
|
||||
|
||||
watch(bgOn, () => void syncBg());
|
||||
watch(bgOpacity, () => bgOn.value && syncOpacity());
|
||||
watch(anim, (on) => document.documentElement.classList.toggle('no-page-anim', !on), {
|
||||
immediate: true,
|
||||
});
|
||||
watch(bg, () => {
|
||||
if (!bgOn.value || !bg.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute(
|
||||
'style',
|
||||
[
|
||||
'background-color: unset',
|
||||
'display: block',
|
||||
'min-height: 100%',
|
||||
'min-width: 100%',
|
||||
`background-image: url(${bg.value})`,
|
||||
].join('; '),
|
||||
);
|
||||
document.documentElement.classList.add('bg-fanart');
|
||||
syncOpacity();
|
||||
});
|
||||
</script>
|
||||
43
ui/app/components/ConnectionBanner.vue
Normal file
43
ui/app/components/ConnectionBanner.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<UAlert
|
||||
v-if="sock.connectionStatus !== 'connected'"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
orientation="horizontal"
|
||||
:title="title"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon
|
||||
:name="icon"
|
||||
:class="[
|
||||
'size-4 shrink-0 text-warning',
|
||||
sock.connectionStatus === 'connecting' ? 'animate-spin' : '',
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="sock.connectionStatus === 'disconnected'" #actions>
|
||||
<UButton color="neutral" variant="link" size="sm" class="px-0" @click="sock.reconnect()">
|
||||
Reconnect
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const sock = useAppSocket();
|
||||
|
||||
const title = computed(() => {
|
||||
if (sock.connectionStatus === 'connecting') {
|
||||
return 'Connecting to websocket server...';
|
||||
}
|
||||
|
||||
return 'Websocket connection lost.';
|
||||
});
|
||||
|
||||
const icon = computed(() =>
|
||||
sock.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info',
|
||||
);
|
||||
</script>
|
||||
|
|
@ -35,13 +35,7 @@
|
|||
variant="soft"
|
||||
icon="i-lucide-filter"
|
||||
title="No matching lines"
|
||||
>
|
||||
<template #description>
|
||||
<p class="text-sm text-default">
|
||||
No lines match this filter: <u>{{ query }}</u>
|
||||
</p>
|
||||
</template>
|
||||
</UAlert>
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-else-if="!hasDisplayText"
|
||||
|
|
@ -52,19 +46,30 @@
|
|||
description="The request completed successfully but did not return any visible content."
|
||||
/>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-md border border-default bg-elevated/30">
|
||||
<code
|
||||
ref="contentView"
|
||||
class="block max-h-[55vh] overflow-auto p-4 font-mono text-sm leading-6 text-default whitespace-pre-wrap wrap-break-word"
|
||||
v-text="displayedText"
|
||||
/>
|
||||
</div>
|
||||
<pre
|
||||
v-else-if="!query || filteredLineCount > 0"
|
||||
ref="contentView"
|
||||
class="ytp-terminal max-h-[55vh] overflow-auto"
|
||||
:class="wrap ? 'whitespace-pre-wrap wrap-break-word' : 'whitespace-pre'"
|
||||
><code v-text="displayedText" /></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-wrap items-center justify-end gap-2">
|
||||
<UButton
|
||||
type="button"
|
||||
color="neutral"
|
||||
:variant="wrap ? 'soft' : 'outline'"
|
||||
size="sm"
|
||||
icon="i-lucide-wrap-text"
|
||||
:disabled="isLoading || !hasVisibleText"
|
||||
@click="wrap = !wrap"
|
||||
>
|
||||
<span class="hidden sm:inline">Wrap</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
type="button"
|
||||
color="neutral"
|
||||
|
|
@ -119,7 +124,8 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const toast = useNotification();
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { filterLogTextLines } from '~/utils/logs';
|
||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
||||
|
||||
const props = withDefaults(
|
||||
|
|
@ -141,9 +147,12 @@ const isLoading = ref(true);
|
|||
const data = ref<unknown>(null);
|
||||
const errorMessage = ref('');
|
||||
const query = ref('');
|
||||
const wrap = useStorage<boolean>('getinfo_wrap', false);
|
||||
const contentView = ref<HTMLElement | null>(null);
|
||||
let latestRequestId = 0;
|
||||
|
||||
const toast = useNotification();
|
||||
|
||||
const modalUi = {
|
||||
content: 'w-full sm:max-w-5xl',
|
||||
body: 'p-4 sm:p-5',
|
||||
|
|
@ -199,22 +208,9 @@ const displayText = computed(() => {
|
|||
}
|
||||
});
|
||||
|
||||
const lines = computed<Array<string>>(() => {
|
||||
if (!displayText.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return displayText.value.split('\n');
|
||||
});
|
||||
|
||||
const filteredLines = computed<Array<string>>(() => {
|
||||
if (!query.value) {
|
||||
return lines.value;
|
||||
}
|
||||
|
||||
const needle = query.value.toLowerCase();
|
||||
return lines.value.filter((line) => line.toLowerCase().includes(needle));
|
||||
});
|
||||
const filteredLines = computed<Array<string>>(() =>
|
||||
filterLogTextLines(displayText.value, query.value),
|
||||
);
|
||||
|
||||
const filteredLineCount = computed(() => filteredLines.value.length);
|
||||
const displayedText = computed(() =>
|
||||
|
|
|
|||
|
|
@ -10,97 +10,142 @@
|
|||
>
|
||||
<template #body>
|
||||
<div v-if="log" class="space-y-5">
|
||||
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<UBadge
|
||||
:color="LOG_LEVEL_COLOR[getLogLevel(log.level)]"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
class="uppercase"
|
||||
>
|
||||
<UIcon :name="LOG_LEVEL_ICON[getLogLevel(log.level)]" class="mr-1 size-3.5" />
|
||||
{{ getLogLevel(log.level) }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-if="log.logger"
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<StatCard
|
||||
label="Level"
|
||||
:value="getLogLevel(log.level)"
|
||||
:icon="LOG_LEVEL_ICON[getLogLevel(log.level)]"
|
||||
:color="LOG_LEVEL_COLOR[getLogLevel(log.level)]"
|
||||
/>
|
||||
<StatCard v-if="log.logger" label="Logger" :value="log.logger" icon="i-lucide-tag" />
|
||||
<StatCard
|
||||
label="Date"
|
||||
:value="moment(log.datetime).fromNow()"
|
||||
icon="i-lucide-clock"
|
||||
:tooltip="moment(log.datetime).format('YYYY-MM-DD HH:mm:ss Z')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<UDropdownMenu :items="copyMenuItems" :content="{ align: 'end' }" :modal="false">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="max-w-full min-w-0"
|
||||
:title="log.logger"
|
||||
icon="i-lucide-copy"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
<UIcon name="i-lucide-tag" class="mr-1 size-3.5" />
|
||||
<span class="min-w-0 max-w-full truncate">{{ log.logger }}</span>
|
||||
</UBadge>
|
||||
<span class="text-xs text-toned">{{ logTimeTitle(log.datetime) }}</span>
|
||||
</div>
|
||||
Copy
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
<UDropdownMenu :items="copyMenuItems" :content="{ align: 'end' }" :modal="false">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-copy"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
Copy
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 space-y-2 sm:col-span-2">
|
||||
<UAlert
|
||||
:color="LOG_LEVEL_COLOR[getLogLevel(log.level)]"
|
||||
variant="soft"
|
||||
:icon="LOG_LEVEL_ICON[getLogLevel(log.level)]"
|
||||
title=""
|
||||
class="min-w-0"
|
||||
>
|
||||
<template #description>
|
||||
<p class="wrap-break-word w-full font-mono text-sm text-default">
|
||||
{{ log.message }}
|
||||
</p>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UAlert
|
||||
v-if="exceptionSummary(log)"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-badge-alert"
|
||||
:title="exceptionSummary(log)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UAlert
|
||||
v-if="exceptionSummary(log)"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-badge-alert"
|
||||
:title="exceptionSummary(log)"
|
||||
/>
|
||||
|
||||
<section v-if="log.exception" class="space-y-2">
|
||||
<section v-if="log.exception" class="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
|
||||
class="flex w-full flex-wrap items-center justify-between gap-3 text-left"
|
||||
@click="exceptionOpen = !exceptionOpen"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
:class="['size-4 transition-transform', exceptionOpen ? 'rotate-90' : '']"
|
||||
/>
|
||||
<UIcon name="i-lucide-bug" class="size-4 text-error" />
|
||||
Exception
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-error/30 bg-error/5 text-error"
|
||||
>
|
||||
<UIcon name="i-lucide-bug" class="size-4" />
|
||||
</span>
|
||||
<p class="text-base font-semibold text-highlighted">Exception</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" @click.stop>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="neutral"
|
||||
:variant="wrapException ? 'soft' : 'outline'"
|
||||
icon="i-lucide-wrap-text"
|
||||
@click="wrapException = !wrapException"
|
||||
>
|
||||
<span class="hidden sm:inline">Wrap</span>
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-copy"
|
||||
@click="copyText(exceptionText(log), true)"
|
||||
>
|
||||
Copy
|
||||
</UButton>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
:class="[
|
||||
'size-4 text-toned transition-transform',
|
||||
exceptionOpen ? 'rotate-90' : '',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<pre
|
||||
v-if="exceptionOpen"
|
||||
class="max-h-72 overflow-auto rounded-sm border border-error/30 bg-error/5 p-3 text-xs whitespace-pre-wrap text-error"
|
||||
>{{ exceptionText(log) }}</pre
|
||||
>
|
||||
class="ytp-terminal max-h-96 overflow-auto"
|
||||
:class="wrapException ? 'whitespace-pre-wrap wrap-break-word' : 'whitespace-pre'"
|
||||
><code>{{ exceptionText(log) }}</code></pre>
|
||||
</section>
|
||||
|
||||
<section v-if="detailRows(log).length > 0" class="space-y-2">
|
||||
<section v-if="detailRows.length > 0" class="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
|
||||
class="flex w-full flex-wrap items-center justify-between gap-3 text-left"
|
||||
@click="sourceOpen = !sourceOpen"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
:class="['size-4 transition-transform', sourceOpen ? 'rotate-90' : '']"
|
||||
/>
|
||||
<UIcon name="i-lucide-file-code" class="size-4 text-info" />
|
||||
Source
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon name="i-lucide-file-code" class="size-4" />
|
||||
</span>
|
||||
<p class="text-base font-semibold text-highlighted">Source</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" @click.stop>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-copy"
|
||||
@click="copyText(sourceJson, true)"
|
||||
>
|
||||
Copy
|
||||
</UButton>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
:class="['size-4 text-toned transition-transform', sourceOpen ? 'rotate-90' : '']"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<dl v-if="sourceOpen" class="grid gap-2 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="row in detailRows(log)"
|
||||
v-for="row in detailRows"
|
||||
:key="row.label"
|
||||
class="rounded-sm border border-default bg-elevated/40 p-3"
|
||||
>
|
||||
|
|
@ -115,22 +160,66 @@
|
|||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="fieldRows(log).length > 0" class="space-y-2">
|
||||
<section v-if="fieldRows.length > 0" class="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
|
||||
class="flex w-full flex-wrap items-center justify-between gap-3 text-left"
|
||||
@click="fieldsOpen = !fieldsOpen"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
:class="['size-4 transition-transform', fieldsOpen ? 'rotate-90' : '']"
|
||||
/>
|
||||
<UIcon name="i-lucide-tags" class="size-4 text-primary" />
|
||||
Fields
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon name="i-lucide-tags" class="size-4" />
|
||||
</span>
|
||||
<p class="text-base font-semibold text-highlighted">Fields</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" @click.stop>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="neutral"
|
||||
:variant="wrapFields ? 'soft' : 'outline'"
|
||||
icon="i-lucide-wrap-text"
|
||||
@click="wrapFields = !wrapFields"
|
||||
>
|
||||
<span class="hidden sm:inline">Wrap</span>
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-copy"
|
||||
@click="copyText(displayedFieldsJson, true)"
|
||||
>
|
||||
Copy
|
||||
</UButton>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
:class="['size-4 text-toned transition-transform', fieldsOpen ? 'rotate-90' : '']"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="fieldsOpen" class="space-y-2">
|
||||
<UInput
|
||||
v-model="fieldsQuery"
|
||||
type="search"
|
||||
icon="i-lucide-filter"
|
||||
placeholder="Filter fields"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="fieldsQuery && 0 === visibleFieldRows.length"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-filter"
|
||||
title="No matching fields"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="field in fieldRows(log)"
|
||||
v-for="field in visibleFieldRows"
|
||||
:key="field.key"
|
||||
class="rounded-sm border border-default bg-elevated/40"
|
||||
>
|
||||
|
|
@ -158,37 +247,50 @@
|
|||
|
||||
<div v-if="fieldOpen(field.key)" class="border-t border-default/70 px-3 py-3">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<UInput
|
||||
v-if="field.kind !== 'scalar'"
|
||||
:model-value="fieldFilter(field.key)"
|
||||
type="search"
|
||||
icon="i-lucide-filter"
|
||||
placeholder="Filter field lines"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@update:model-value="setFieldFilter(field.key, $event)"
|
||||
/>
|
||||
<div v-if="field.kind !== 'scalar'" class="w-full">
|
||||
<UInput
|
||||
v-model="fieldFilters[field.key]"
|
||||
type="search"
|
||||
icon="i-lucide-filter"
|
||||
placeholder="Filter field lines"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
size="sm"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-copy"
|
||||
@click="copyFieldValue(field)"
|
||||
>
|
||||
Copy
|
||||
</UButton>
|
||||
@click="copyText(displayedFieldValue(field), true)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="fieldFilter(field.key) && 0 === filteredFieldLineCount(field)"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-filter"
|
||||
title="No matching lines"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<pre
|
||||
v-if="field.kind === 'json'"
|
||||
class="max-h-96 overflow-auto rounded-sm border border-default bg-elevated/50 p-3 text-xs whitespace-pre-wrap text-default"
|
||||
>{{ displayedFieldValue(field) }}</pre
|
||||
>
|
||||
v-if="
|
||||
field.kind === 'json' &&
|
||||
(!fieldFilter(field.key) || filteredFieldLineCount(field) > 0)
|
||||
"
|
||||
class="ytp-terminal max-h-96 overflow-auto"
|
||||
:class="wrapFields ? 'whitespace-pre-wrap wrap-break-word' : 'whitespace-pre'"
|
||||
><code>{{ displayedFieldValue(field) }}</code></pre>
|
||||
<pre
|
||||
v-else-if="field.kind === 'text'"
|
||||
class="max-h-96 overflow-auto rounded-sm border border-default bg-elevated/50 p-3 text-xs whitespace-pre-wrap text-default"
|
||||
>{{ displayedFieldValue(field) }}</pre
|
||||
>
|
||||
v-else-if="
|
||||
field.kind === 'text' &&
|
||||
(!fieldFilter(field.key) || filteredFieldLineCount(field) > 0)
|
||||
"
|
||||
class="ytp-terminal max-h-96 overflow-auto"
|
||||
:class="wrapFields ? 'whitespace-pre-wrap wrap-break-word' : 'whitespace-pre'"
|
||||
><code>{{ displayedFieldValue(field) }}</code></pre>
|
||||
<p v-else class="wrap-break-word font-mono text-xs text-default">
|
||||
{{ field.value }}
|
||||
</p>
|
||||
|
|
@ -197,44 +299,70 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<section class="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-toned hover:text-default"
|
||||
class="flex w-full flex-wrap items-center justify-between gap-3 text-left"
|
||||
@click="rawJsonOpen = !rawJsonOpen"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
:class="['size-4 transition-transform', rawJsonOpen ? 'rotate-90' : '']"
|
||||
/>
|
||||
<UIcon name="i-lucide-braces" class="size-4 text-toned" />
|
||||
RAW DATA
|
||||
</button>
|
||||
<div v-if="rawJsonOpen" class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<UInput
|
||||
v-model="rawJsonFilter"
|
||||
type="search"
|
||||
icon="i-lucide-filter"
|
||||
placeholder="Filter lines"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon name="i-lucide-braces" class="size-4" />
|
||||
</span>
|
||||
<p class="text-base font-semibold text-highlighted">Raw Data</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" @click.stop>
|
||||
<UButton
|
||||
size="xs"
|
||||
size="sm"
|
||||
color="neutral"
|
||||
:variant="wrapRaw ? 'soft' : 'outline'"
|
||||
icon="i-lucide-wrap-text"
|
||||
@click="wrapRaw = !wrapRaw"
|
||||
>
|
||||
<span class="hidden sm:inline">Wrap</span>
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-copy"
|
||||
@click="log && copyText(logRaw(log))"
|
||||
@click="copyText(displayedRawJson, true)"
|
||||
>
|
||||
Copy
|
||||
</UButton>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
:class="['size-4 text-toned transition-transform', rawJsonOpen ? 'rotate-90' : '']"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<template v-if="rawJsonOpen">
|
||||
<UInput
|
||||
v-model="rawJsonFilter"
|
||||
type="search"
|
||||
icon="i-lucide-filter"
|
||||
placeholder="Filter raw data"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="rawJsonFilter && 0 === filteredRawJsonLineCount"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-filter"
|
||||
title="No matching lines"
|
||||
/>
|
||||
|
||||
<pre
|
||||
class="max-h-96 overflow-auto rounded-sm border border-default bg-elevated/50 p-3 text-xs whitespace-pre-wrap text-default"
|
||||
>{{ filteredRawJson }}</pre
|
||||
>
|
||||
</div>
|
||||
v-if="!rawJsonFilter || filteredRawJsonLineCount > 0"
|
||||
class="ytp-terminal max-h-96 overflow-auto"
|
||||
:class="wrapRaw ? 'whitespace-pre-wrap wrap-break-word' : 'whitespace-pre'"
|
||||
><code>{{ displayedRawJson }}</code></pre>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -244,6 +372,9 @@
|
|||
<script setup lang="ts">
|
||||
import moment from 'moment';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { computed, ref } from 'vue';
|
||||
import StatCard from '~/components/StatCard.vue';
|
||||
import { filterLogTextLines } from '~/utils/logs';
|
||||
import type { log_line } from '~/types/logs';
|
||||
import { copyText } from '~/utils';
|
||||
|
||||
|
|
@ -292,10 +423,15 @@ const LOG_LEVEL_ICON: Record<LogLevel, string> = {
|
|||
const exceptionOpen = useStorage<boolean>('logs_exception_open', false);
|
||||
const fieldsOpen = useStorage<boolean>('logs_fields_open', true);
|
||||
const rawJsonOpen = useStorage<boolean>('logs_raw_json_open', false);
|
||||
const rawJsonFilter = ref('');
|
||||
const sourceOpen = useStorage<boolean>('logs_source_open', true);
|
||||
const wrapException = useStorage<boolean>('logs_wrap_exception', false);
|
||||
const wrapFields = useStorage<boolean>('logs_wrap_fields', false);
|
||||
const wrapRaw = useStorage<boolean>('logs_wrap_raw', false);
|
||||
|
||||
const rawJsonFilter = ref('');
|
||||
const fieldOpenState = ref<Record<string, boolean>>({});
|
||||
const fieldFilters = ref<Record<string, string>>({});
|
||||
const fieldsQuery = ref<string>('');
|
||||
|
||||
const getLogLevel = (level: string): LogLevel => {
|
||||
switch (level.toLowerCase()) {
|
||||
|
|
@ -329,8 +465,19 @@ const exceptionSummary = (log: log_line): string => {
|
|||
const exceptionText = (log: log_line): string =>
|
||||
log.exception ? JSON.stringify(log.exception, null, 2) : '';
|
||||
|
||||
const logTimeTitle = (value?: string): string =>
|
||||
value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp';
|
||||
const rawJson = computed(() => (props.log ? logRaw(props.log) : ''));
|
||||
|
||||
const sourceJson = computed(() =>
|
||||
JSON.stringify(
|
||||
{
|
||||
source: props.log?.source ?? null,
|
||||
process: props.log?.process ?? null,
|
||||
thread: props.log?.thread ?? null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const copyMenuItems = computed(() => [
|
||||
[
|
||||
|
|
@ -348,22 +495,20 @@ const copyMenuItems = computed(() => [
|
|||
icon: 'i-lucide-braces',
|
||||
onSelect: () => {
|
||||
if (props.log) {
|
||||
copyText(logRaw(props.log));
|
||||
copyText(rawJson.value);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const filteredRawJson = computed(() => {
|
||||
const raw = props.log ? logRaw(props.log) : '';
|
||||
const query = rawJsonFilter.value.toLowerCase();
|
||||
if (!query) return raw;
|
||||
return raw
|
||||
.split('\n')
|
||||
.filter((line) => line.toLowerCase().includes(query))
|
||||
.join('\n');
|
||||
});
|
||||
const filteredRawJsonLines = computed<Array<string>>(() =>
|
||||
filterLogTextLines(rawJson.value, rawJsonFilter.value),
|
||||
);
|
||||
const filteredRawJsonLineCount = computed<number>(() => filteredRawJsonLines.value.length);
|
||||
const displayedRawJson = computed<string>(() =>
|
||||
rawJsonFilter.value ? filteredRawJsonLines.value.join('\n') : rawJson.value,
|
||||
);
|
||||
|
||||
const formatDetailValue = (value: unknown): string => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
|
|
@ -400,79 +545,31 @@ const formatNameId = (name: unknown, id: unknown): string => {
|
|||
return nameValue || idValue;
|
||||
};
|
||||
|
||||
const detailRows = (log: log_line): DetailRow[] =>
|
||||
compactRows([
|
||||
{ label: 'File', value: log.source?.file, icon: 'i-lucide-file' },
|
||||
{ label: 'Line', value: log.source?.line, icon: 'i-lucide-hash' },
|
||||
{ label: 'Function', value: log.source?.function, icon: 'i-lucide-code-2' },
|
||||
{ label: 'Module', value: log.source?.module, icon: 'i-lucide-box' },
|
||||
{ label: 'Path', value: log.source?.path, icon: 'i-lucide-folder-tree' },
|
||||
const detailRows = computed((): DetailRow[] => {
|
||||
if (!props.log) return [];
|
||||
return compactRows([
|
||||
{ label: 'File', value: props.log.source?.file, icon: 'i-lucide-file' },
|
||||
{ label: 'Line', value: props.log.source?.line, icon: 'i-lucide-hash' },
|
||||
{ label: 'Function', value: props.log.source?.function, icon: 'i-lucide-code-2' },
|
||||
{ label: 'Module', value: props.log.source?.module, icon: 'i-lucide-box' },
|
||||
{ label: 'Path', value: props.log.source?.path, icon: 'i-lucide-folder-tree' },
|
||||
{
|
||||
label: 'Process / ID',
|
||||
value: formatNameId(log.process?.name, log.process?.id),
|
||||
value: formatNameId(props.log.process?.name, props.log.process?.id),
|
||||
icon: 'i-lucide-cpu',
|
||||
},
|
||||
{
|
||||
label: 'Thread / ID',
|
||||
value: formatNameId(log.thread?.name, log.thread?.id),
|
||||
value: formatNameId(props.log.thread?.name, props.log.thread?.id),
|
||||
icon: 'i-lucide-git-branch',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const fieldRows = (log: log_line): LogFieldRow[] => {
|
||||
if (!log.fields) return [];
|
||||
const rows: LogFieldRow[] = [];
|
||||
for (const [key, rawValue] of Object.entries(log.fields)) {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') continue;
|
||||
const jsonValue =
|
||||
typeof rawValue === 'string'
|
||||
? parseJsonContainerString(rawValue)
|
||||
: isJsonContainer(rawValue)
|
||||
? rawValue
|
||||
: null;
|
||||
const value = jsonValue ? JSON.stringify(jsonValue, null, 2) : formatFieldValue(rawValue);
|
||||
const kind = jsonValue
|
||||
? 'json'
|
||||
: typeof rawValue === 'string'
|
||||
? rawValue.includes('\n')
|
||||
? 'text'
|
||||
: 'scalar'
|
||||
: 'scalar';
|
||||
rows.push({
|
||||
key,
|
||||
label: normalizeFieldLabel(key),
|
||||
value,
|
||||
preview: formatFieldValue(rawValue).replaceAll('\n', ' '),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const fieldOpen = (key: string) => fieldOpenState.value[key] ?? false;
|
||||
|
||||
const toggleField = (key: string) => {
|
||||
fieldOpenState.value = { ...fieldOpenState.value, [key]: !fieldOpen(key) };
|
||||
};
|
||||
|
||||
const fieldFilter = (key: string) => fieldFilters.value[key] ?? '';
|
||||
|
||||
const setFieldFilter = (key: string, value: string | number) => {
|
||||
fieldFilters.value = { ...fieldFilters.value, [key]: String(value ?? '') };
|
||||
};
|
||||
|
||||
const displayedFieldValue = (field: LogFieldRow): string => {
|
||||
const query = fieldFilter(field.key);
|
||||
if (!query) return field.value;
|
||||
const needle = query.toLowerCase();
|
||||
return field.value
|
||||
.split('\n')
|
||||
.filter((line) => line.toLowerCase().includes(needle))
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const copyFieldValue = (field: LogFieldRow): void => {
|
||||
copyText(field.value);
|
||||
const formatFieldValue = (value: unknown): string => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return JSON.stringify(value, null, 2);
|
||||
};
|
||||
|
||||
const isJsonLike = (value: string): boolean => {
|
||||
|
|
@ -499,11 +596,86 @@ const parseJsonContainerString = (value: string): Record<string, unknown> | unkn
|
|||
return null;
|
||||
};
|
||||
|
||||
const normalizeFieldLabel = (key: string) => key.replaceAll('_', ' ');
|
||||
const fieldRows = computed((): LogFieldRow[] => {
|
||||
if (!props.log?.fields) return [];
|
||||
const rows: LogFieldRow[] = [];
|
||||
for (const [key, rawValue] of Object.entries(props.log.fields)) {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') continue;
|
||||
const jsonValue =
|
||||
typeof rawValue === 'string'
|
||||
? parseJsonContainerString(rawValue)
|
||||
: isJsonContainer(rawValue)
|
||||
? rawValue
|
||||
: null;
|
||||
const value = jsonValue ? JSON.stringify(jsonValue, null, 2) : formatFieldValue(rawValue);
|
||||
const kind: LogFieldRow['kind'] = jsonValue
|
||||
? 'json'
|
||||
: typeof rawValue === 'string'
|
||||
? rawValue.includes('\n')
|
||||
? 'text'
|
||||
: 'scalar'
|
||||
: 'scalar';
|
||||
rows.push({
|
||||
key,
|
||||
label: key,
|
||||
value,
|
||||
preview: formatFieldValue(rawValue).replaceAll('\n', ' '),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const formatFieldValue = (value: unknown): string => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return JSON.stringify(value, null, 2);
|
||||
const visibleFieldRows = computed((): LogFieldRow[] => {
|
||||
const query = fieldsQuery.value.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return fieldRows.value;
|
||||
}
|
||||
|
||||
if (query.includes('.')) {
|
||||
const keyMatches = fieldRows.value.filter((field) => field.key.toLowerCase().includes(query));
|
||||
if (keyMatches.length > 0) {
|
||||
return keyMatches;
|
||||
}
|
||||
}
|
||||
|
||||
return fieldRows.value.filter((field) => {
|
||||
const haystack = `${field.key}\n${field.preview}\n${field.value}`.toLowerCase();
|
||||
return haystack.includes(query) || filterLogTextLines(field.value, query).length > 0;
|
||||
});
|
||||
});
|
||||
|
||||
const displayedFieldsJson = computed(() => {
|
||||
const allFields = props.log?.fields ?? {};
|
||||
if (!fieldsQuery.value.trim()) {
|
||||
return JSON.stringify(allFields, null, 2);
|
||||
}
|
||||
|
||||
const visibleKeys = new Set(visibleFieldRows.value.map((f) => f.key));
|
||||
const filtered: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(allFields)) {
|
||||
if (visibleKeys.has(key)) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(filtered, null, 2);
|
||||
});
|
||||
|
||||
const fieldOpen = (key: string) => fieldOpenState.value[key] ?? false;
|
||||
|
||||
const toggleField = (key: string) => {
|
||||
fieldOpenState.value = { ...fieldOpenState.value, [key]: !fieldOpen(key) };
|
||||
};
|
||||
|
||||
const fieldFilter = (key: string) => fieldFilters.value[key] ?? '';
|
||||
|
||||
const displayedFieldValue = (field: LogFieldRow): string => {
|
||||
const query = fieldFilter(field.key);
|
||||
if (!query) return field.value;
|
||||
return filterLogTextLines(field.value, query).join('\n');
|
||||
};
|
||||
|
||||
const filteredFieldLineCount = (field: LogFieldRow): number =>
|
||||
filterLogTextLines(field.value, fieldFilter(field.key)).length;
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -264,6 +264,10 @@
|
|||
.markdown-alert-caution {
|
||||
--markdown-alert-accent: var(--ui-error);
|
||||
}
|
||||
.docs-markdown details summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@
|
|||
size="sm"
|
||||
square
|
||||
icon="i-lucide-x"
|
||||
class="ml-auto shrink-0 sm:hidden"
|
||||
aria-label="Close settings"
|
||||
title="Close settings"
|
||||
class="ml-auto shrink-0"
|
||||
@click="emitter('close')"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -37,14 +39,31 @@
|
|||
</template>
|
||||
|
||||
<template #body>
|
||||
<USwitch
|
||||
v-model="simpleMode"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:ui="settingsSwitchUi"
|
||||
:label="simpleMode ? 'Simple View' : 'Regular View'"
|
||||
description="The simple view is ideal for non-technical users and mobile devices."
|
||||
/>
|
||||
<UFormField label="" class="w-full" :ui="settingsFieldUi">
|
||||
<USelect
|
||||
v-model="draftMode"
|
||||
:items="modeItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:ui="{ base: 'w-full' }"
|
||||
/>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<UButton
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
icon="i-lucide-save"
|
||||
:disabled="!modeChanged || savingMode"
|
||||
:loading="savingMode"
|
||||
@click="saveMode"
|
||||
>
|
||||
Save layout
|
||||
</UButton>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<USwitch
|
||||
v-model="page_anims"
|
||||
|
|
@ -91,12 +110,12 @@
|
|||
class="w-full"
|
||||
v-if="bg_enable"
|
||||
label="Background visibility"
|
||||
:hint="String(parseFloat(String(1.0 - bg_opacity)).toFixed(2))"
|
||||
:hint="`${Math.round(bgVisibilityModel * 100)}%`"
|
||||
>
|
||||
<USlider
|
||||
v-model="bgOpacityModel"
|
||||
:min="0.5"
|
||||
:max="1"
|
||||
v-model="bgVisibilityModel"
|
||||
:min="0"
|
||||
:max="0.5"
|
||||
:step="0.01"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
|
|
@ -114,12 +133,7 @@
|
|||
</template>
|
||||
|
||||
<template #body>
|
||||
<UFormField
|
||||
v-if="!simpleMode"
|
||||
label="URL Separator"
|
||||
class="w-full"
|
||||
:ui="settingsFieldUi"
|
||||
>
|
||||
<UFormField v-if="!modeOn" label="URL Separator" class="w-full" :ui="settingsFieldUi">
|
||||
<USelect
|
||||
v-model="separator"
|
||||
:items="separatorItems"
|
||||
|
|
@ -286,6 +300,7 @@ import { watch, onMounted, onBeforeUnmount, ref, computed } from 'vue';
|
|||
import { useStorage } from '@vueuse/core';
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import type { notificationTarget, toastPosition } from '~/composables/useNotification';
|
||||
import { useMode, type Mode } from '~/composables/useMode';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
|
@ -302,7 +317,6 @@ const props = withDefaults(
|
|||
|
||||
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>();
|
||||
|
||||
const config = useYtpConfig();
|
||||
const notification = useNotification();
|
||||
|
||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
||||
|
|
@ -315,7 +329,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
|
|||
const show_popover = useStorage<boolean>('show_popover', true);
|
||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
|
||||
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',');
|
||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
||||
const { mode, on: modeOn, save: applyMode } = useMode();
|
||||
const draftMode = ref<Mode>(mode.value);
|
||||
const savingMode = ref(false);
|
||||
const page_anims = useStorage<boolean>('page_anims', true);
|
||||
const queue_auto_refresh = useStorage<boolean>('queue_auto_refresh', true);
|
||||
const queue_auto_refresh_delay = useStorage<number>('queue_auto_refresh_delay', 10000);
|
||||
|
|
@ -338,10 +354,10 @@ const settingsSwitchUi = {
|
|||
wrapper: 'ms-0 flex-1 text-sm',
|
||||
};
|
||||
|
||||
const bgOpacityModel = computed<number>({
|
||||
get: () => Number(bg_opacity.value),
|
||||
const bgVisibilityModel = computed<number>({
|
||||
get: () => Number((1 - Number(bg_opacity.value)).toFixed(2)),
|
||||
set: (value) => {
|
||||
bg_opacity.value = Number(value);
|
||||
bg_opacity.value = Number((1 - Number(value)).toFixed(2));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -356,6 +372,30 @@ const separatorItems = computed(() =>
|
|||
separators.map((sep) => ({ label: `${sep.name} (${sep.value})`, value: sep.value })),
|
||||
);
|
||||
|
||||
const modeItems = computed<Array<{ label: string; value: Mode }>>(() => [
|
||||
{
|
||||
label: `Default`,
|
||||
value: 'default',
|
||||
},
|
||||
{ label: 'Simple', value: 'simple' },
|
||||
{ label: 'Regular', value: 'regular' },
|
||||
]);
|
||||
|
||||
const modeChanged = computed(() => draftMode.value !== mode.value);
|
||||
|
||||
const saveMode = async (): Promise<void> => {
|
||||
if (!modeChanged.value || savingMode.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
savingMode.value = true;
|
||||
try {
|
||||
await applyMode(draftMode.value);
|
||||
} finally {
|
||||
savingMode.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const thumbnailRatioItems = [
|
||||
{ label: '16:9', value: 'is-16by9' },
|
||||
{ label: '3:1', value: 'is-3by1' },
|
||||
|
|
@ -412,12 +452,21 @@ watch(
|
|||
() => props.isOpen,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
draftMode.value = mode.value;
|
||||
document.body.classList.add('settings-panel-open');
|
||||
} else {
|
||||
document.body.classList.remove('settings-panel-open');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(mode, (value) => {
|
||||
if (props.isOpen && modeChanged.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
draftMode.value = value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
73
ui/app/components/StatCard.vue
Normal file
73
ui/app/components/StatCard.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<div class="rounded-lg border border-default/70 bg-elevated/40 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-toned">{{ label }}</p>
|
||||
<UTooltip v-if="tooltip" :text="tooltip" :content="{ side: 'left' }">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-sm font-semibold text-highlighted">{{ value }}</span>
|
||||
<span v-if="hint" class="truncate text-xs text-toned">{{ hint }}</span>
|
||||
</div>
|
||||
</UTooltip>
|
||||
<div v-else class="flex items-baseline gap-1.5">
|
||||
<span class="text-sm font-semibold text-highlighted">{{ value }}</span>
|
||||
<span v-if="hint" class="truncate text-xs text-toned">{{ hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset',
|
||||
iconTileClass,
|
||||
]"
|
||||
>
|
||||
<UIcon :name="icon" :class="['size-4', iconTextClass]" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Color = 'primary' | 'success' | 'error' | 'warning' | 'info' | 'neutral';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: string;
|
||||
hint?: string;
|
||||
tooltip?: string;
|
||||
color?: Color;
|
||||
}>(),
|
||||
{ color: 'primary', hint: '', tooltip: '' },
|
||||
);
|
||||
|
||||
const TILE_BG: Record<Color, string> = {
|
||||
primary: 'bg-elevated',
|
||||
success: 'bg-success/10',
|
||||
error: 'bg-error/10',
|
||||
warning: 'bg-warning/10',
|
||||
info: 'bg-info/10',
|
||||
neutral: 'bg-elevated',
|
||||
};
|
||||
|
||||
const TILE_RING: Record<Color, string> = {
|
||||
primary: 'ring-default',
|
||||
success: 'ring-success/30',
|
||||
error: 'ring-error/30',
|
||||
warning: 'ring-warning/30',
|
||||
info: 'ring-info/30',
|
||||
neutral: 'ring-default',
|
||||
};
|
||||
|
||||
const TILE_TEXT: Record<Color, string> = {
|
||||
primary: 'text-primary',
|
||||
success: 'text-success',
|
||||
error: 'text-error',
|
||||
warning: 'text-warning',
|
||||
info: 'text-info',
|
||||
neutral: 'text-toned',
|
||||
};
|
||||
|
||||
const iconTileClass = computed(() => `${TILE_BG[props.color]} ${TILE_RING[props.color]}`);
|
||||
const iconTextClass = computed(() => TILE_TEXT[props.color]);
|
||||
</script>
|
||||
66
ui/app/components/ThemeButton.vue
Normal file
66
ui/app/components/ThemeButton.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<UTooltip :text="title">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:icon="icon"
|
||||
:square="square"
|
||||
:aria-label="title"
|
||||
:title="title"
|
||||
@click="color.preference = next"
|
||||
>
|
||||
<span v-if="showLabel" :class="labelClass">{{ title }}</span>
|
||||
</UButton>
|
||||
</UTooltip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
square?: boolean;
|
||||
showLabel?: boolean;
|
||||
labelClass?: string;
|
||||
}>(),
|
||||
{
|
||||
square: false,
|
||||
showLabel: true,
|
||||
labelClass: '',
|
||||
},
|
||||
);
|
||||
|
||||
type Choice = 'system' | 'light' | 'dark';
|
||||
|
||||
const opts: Array<Choice> = ['system', 'light', 'dark'];
|
||||
const color = useColorMode();
|
||||
const current = computed<Choice>(() =>
|
||||
opts.includes(color.preference as Choice) ? (color.preference as Choice) : 'system',
|
||||
);
|
||||
const next = computed<Choice>(
|
||||
() => opts[(opts.indexOf(current.value) + 1) % opts.length] ?? 'system',
|
||||
);
|
||||
const icon = computed(() => {
|
||||
if (current.value === 'light') {
|
||||
return 'i-lucide-sun';
|
||||
}
|
||||
|
||||
if (current.value === 'dark') {
|
||||
return 'i-lucide-moon';
|
||||
}
|
||||
|
||||
return 'i-lucide-monitor';
|
||||
});
|
||||
const title = computed(() => {
|
||||
if (current.value === 'light') {
|
||||
return 'Light';
|
||||
}
|
||||
|
||||
if (current.value === 'dark') {
|
||||
return 'Dark';
|
||||
}
|
||||
|
||||
return 'System';
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { proxyRefs, readonly, ref } from 'vue';
|
||||
import { useYtpConfig } from '~/composables/useYtpConfig';
|
||||
import { useHistoryState } from '~/composables/useHistoryState';
|
||||
import { useQueueState } from '~/composables/useQueueState';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import type {
|
||||
|
|
@ -17,6 +18,7 @@ type KnownEvent = keyof WSEP;
|
|||
|
||||
const getRuntimeConfig = () => useRuntimeConfig();
|
||||
const getConfig = () => useYtpConfig();
|
||||
const getHistoryState = () => useHistoryState();
|
||||
const getQueueState = () => useQueueState();
|
||||
const getToast = () => useNotification();
|
||||
let queueReloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
|
@ -359,11 +361,15 @@ on('item_deleted', (data: WSEP['item_deleted']) => {
|
|||
on('item_updated', (data: WSEP['item_updated']) => {
|
||||
const queueState = getQueueState();
|
||||
|
||||
if (!queueState.isLoaded()) {
|
||||
return;
|
||||
if (queueState.isLoaded()) {
|
||||
queueState.update(data.data._id, data.data);
|
||||
}
|
||||
|
||||
queueState.update(data.data._id, data.data);
|
||||
const historyState = getHistoryState();
|
||||
|
||||
if (historyState.isLoaded.value) {
|
||||
historyState.update(data.data._id, data.data);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_progress', (data: WSEP['item_progress']) => {
|
||||
|
|
|
|||
|
|
@ -201,6 +201,12 @@ const reset = (): void => {
|
|||
lastError.value = null;
|
||||
};
|
||||
|
||||
const update = (id: string, data: StoreItem): void => {
|
||||
const index = items.value.findIndex((item) => item._id === id);
|
||||
if (index === -1) return;
|
||||
items.value = [...items.value.slice(0, index), data, ...items.value.slice(index + 1)];
|
||||
};
|
||||
|
||||
const upsert = (item: StoreItem): void => {
|
||||
const existingIndex = items.value.findIndex((existing) => existing._id === item._id);
|
||||
|
||||
|
|
@ -252,6 +258,7 @@ export const useHistoryState = () => {
|
|||
remove,
|
||||
rename,
|
||||
reset,
|
||||
update,
|
||||
upsert,
|
||||
moveHandler,
|
||||
};
|
||||
|
|
|
|||
130
ui/app/composables/useMode.ts
Normal file
130
ui/app/composables/useMode.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { computed, nextTick, type ComputedRef, type WritableComputedRef } from 'vue';
|
||||
import { useStorage, type RemovableRef } from '@vueuse/core';
|
||||
|
||||
export type Mode = 'default' | 'simple' | 'regular';
|
||||
type Pref = boolean | null;
|
||||
|
||||
export const MODE_KEY = 'simple_mode';
|
||||
export const SIMPLE_PATH = '/simple';
|
||||
|
||||
const YES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const NO = new Set(['0', 'false', 'no', 'off']);
|
||||
|
||||
let prefRef: RemovableRef<Pref> | null = null;
|
||||
|
||||
export const parseMode = (value: unknown): Pref => {
|
||||
if (Array.isArray(value)) {
|
||||
return parseMode(value[0]);
|
||||
}
|
||||
|
||||
if ('boolean' === typeof value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if ('number' === typeof value) {
|
||||
return value === 1 ? true : value === 0 ? false : null;
|
||||
}
|
||||
|
||||
if ('string' !== typeof value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value.trim().toLowerCase();
|
||||
|
||||
if (YES.has(item)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (NO.has(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const modeFromPref = (pref: Pref): Mode => {
|
||||
if (pref === null) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
return pref ? 'simple' : 'regular';
|
||||
};
|
||||
|
||||
export const prefFromMode = (mode: Mode): Pref => {
|
||||
if (mode === 'default') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mode === 'simple';
|
||||
};
|
||||
|
||||
export const isSimple = (pref: Pref, fallback: boolean): boolean => pref ?? fallback;
|
||||
|
||||
export const cleanPath = (path: string): string => path.replace(/\/+$/, '') || '/';
|
||||
|
||||
export const usePref = (): RemovableRef<Pref> => {
|
||||
if (prefRef === null) {
|
||||
prefRef = useStorage<Pref>(MODE_KEY, null, undefined, {
|
||||
serializer: {
|
||||
read: parseMode,
|
||||
write: (value) => (value === null ? 'null' : String(value)),
|
||||
},
|
||||
writeDefaults: false,
|
||||
});
|
||||
}
|
||||
|
||||
return prefRef;
|
||||
};
|
||||
|
||||
export const cookieDefault = (): Pref => parseMode(useCookie<string | null>(MODE_KEY).value);
|
||||
|
||||
export const savePref = (value: Pref): void => {
|
||||
const pref = usePref();
|
||||
pref.value = value;
|
||||
};
|
||||
|
||||
export const useMode = (): {
|
||||
mode: WritableComputedRef<Mode>;
|
||||
on: ComputedRef<boolean>;
|
||||
def: ComputedRef<boolean>;
|
||||
save: (mode: Mode) => Promise<void>;
|
||||
} => {
|
||||
const cfg = useYtpConfig();
|
||||
const route = useRoute();
|
||||
const pref = usePref();
|
||||
const def = computed(() => Boolean(cfg.is_loaded ? cfg.app.simple_mode : cookieDefault()));
|
||||
const on = computed(() => isSimple(pref.value, def.value));
|
||||
|
||||
const save = async (value: Mode): Promise<void> => {
|
||||
savePref(prefFromMode(value));
|
||||
await nextTick();
|
||||
|
||||
const simpleRoute = cleanPath(route.path) === SIMPLE_PATH;
|
||||
|
||||
if (on.value && !simpleRoute) {
|
||||
await navigateTo(SIMPLE_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!on.value && simpleRoute) {
|
||||
await navigateTo('/');
|
||||
}
|
||||
};
|
||||
|
||||
const mode = computed<Mode>({
|
||||
get: () => modeFromPref(pref.value),
|
||||
set: (value) => {
|
||||
void save(value);
|
||||
},
|
||||
});
|
||||
|
||||
return { mode, on, def, save };
|
||||
};
|
||||
|
||||
export const routeTarget = (path: string, pref: Pref, fallback: boolean): string | null => {
|
||||
if (cleanPath(path) !== '/') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isSimple(pref, fallback) ? SIMPLE_PATH : null;
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
35
ui/app/middleware/mode.global.ts
Normal file
35
ui/app/middleware/mode.global.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { cookieDefault, parseMode, routeTarget, savePref, usePref } from '~/composables/useMode';
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const path = to.path.replace(/\/+$/, '') || '/';
|
||||
const pref = usePref();
|
||||
|
||||
if (to.query.simple !== undefined) {
|
||||
const value = parseMode(to.query.simple);
|
||||
if (value !== null) {
|
||||
savePref(value);
|
||||
}
|
||||
|
||||
const query = { ...to.query };
|
||||
delete query.simple;
|
||||
const target = value === true ? '/simple' : value === false ? '/' : to.path;
|
||||
|
||||
return navigateTo({ path: target, query, hash: to.hash }, { replace: true });
|
||||
}
|
||||
|
||||
if (path !== '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
let def = cookieDefault();
|
||||
if (pref.value === null && def === null) {
|
||||
const cfg = useYtpConfig();
|
||||
await cfg.loadConfig();
|
||||
def = Boolean(cfg.app.simple_mode);
|
||||
}
|
||||
|
||||
const target = routeTarget(path, pref.value, Boolean(def));
|
||||
if (target) {
|
||||
return navigateTo(target, { replace: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -754,7 +754,6 @@ import { requirePageShell } from '~/utils/topLevelNavigation';
|
|||
const config = useYtpConfig();
|
||||
const stateStore = useQueueState();
|
||||
const socket = useAppSocket();
|
||||
const route = useRoute();
|
||||
const toast = useNotification();
|
||||
const box = useConfirm();
|
||||
const { confirmDialog } = useDialog();
|
||||
|
|
@ -889,15 +888,6 @@ const stopAutoRefresh = (): void => {
|
|||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (route.query?.simple !== undefined) {
|
||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
||||
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string);
|
||||
await nextTick();
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('simple');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
|
||||
if (Object.keys(pendingDownloadFormItem.value).length > 0) {
|
||||
await toNewDownload(pendingDownloadFormItem.value);
|
||||
pendingDownloadFormItem.value = {};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,20 @@
|
|||
<style scoped>
|
||||
.message-collapsed {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.message-collapsed {
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
|
|
@ -111,7 +128,7 @@
|
|||
<div class="overflow-hidden rounded-sm border border-default bg-default shadow-sm">
|
||||
<div
|
||||
ref="logContainer"
|
||||
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"
|
||||
class="w-full min-w-0 max-w-full min-h-[55vh] max-h-[60vh] overflow-y-auto overflow-x-hidden bg-transparent font-mono text-sm text-default overscroll-x-contain"
|
||||
@scroll.passive="handleScroll"
|
||||
>
|
||||
<div
|
||||
|
|
@ -151,53 +168,53 @@
|
|||
:class="logRowClass(entry, index)"
|
||||
>
|
||||
<div
|
||||
: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',
|
||||
]"
|
||||
class="flex w-full min-w-0 flex-col gap-1 px-3 py-[0.65rem] leading-[1.6] md:flex-row md:items-start md:gap-2"
|
||||
>
|
||||
<p :class="logLineClass()">
|
||||
<span
|
||||
class="inline-flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 align-middle"
|
||||
>
|
||||
<UTooltip :text="logTimeTitle(entry.log.datetime)">
|
||||
<span class="inline text-[11px] font-semibold text-toned cursor-pointer">
|
||||
{{ logTimeLabel(entry.log.datetime) }}
|
||||
</span>
|
||||
</UTooltip>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-panel-right-open"
|
||||
aria-label="Open log details"
|
||||
class="inline-flex align-[-0.2em] opacity-70 hover:opacity-100"
|
||||
@click="openLogDetails(entry.log)"
|
||||
/>
|
||||
<span
|
||||
:class="logLevelBadgeClass(getLogLevel(entry.log.level))"
|
||||
@click="openLogDetails(entry.log)"
|
||||
>
|
||||
<UIcon
|
||||
:name="LOG_LEVEL_ICON[getLogLevel(entry.log.level)]"
|
||||
class="size-3"
|
||||
/>
|
||||
{{ getLogLevel(entry.log.level) }}
|
||||
<span
|
||||
class="inline-flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 align-middle md:shrink-0 md:flex-nowrap"
|
||||
>
|
||||
<UTooltip :text="logTimeTitle(entry.log.datetime)">
|
||||
<span class="inline text-[11px] font-semibold text-toned cursor-pointer">
|
||||
{{ logTimeLabel(entry.log.datetime) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
entry.log.logger && !['ytptube', 'http_api'].includes(entry.log.logger)
|
||||
"
|
||||
:title="entry.log.logger"
|
||||
class="inline-block max-w-[46vw] truncate align-middle text-[11px] font-semibold text-toned sm:max-w-104"
|
||||
>[{{ entry.log.logger }}]</span
|
||||
>
|
||||
</UTooltip>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-panel-right-open"
|
||||
aria-label="Open log details"
|
||||
class="inline-flex align-[-0.2em] opacity-70 hover:opacity-100"
|
||||
@click="openLogDetails(entry.log)"
|
||||
/>
|
||||
<span
|
||||
:class="logLevelBadgeClass(getLogLevel(entry.log.level))"
|
||||
@click="openLogDetails(entry.log)"
|
||||
>
|
||||
<UIcon :name="LOG_LEVEL_ICON[getLogLevel(entry.log.level)]" class="size-3" />
|
||||
{{ getLogLevel(entry.log.level) }}
|
||||
</span>
|
||||
<span class="ml-2">{{ entry.log.message }}</span>
|
||||
<span v-if="exceptionSummary(entry.log)" class="ml-1 text-error/90">
|
||||
: {{ exceptionSummary(entry.log) }}
|
||||
</span>
|
||||
</p>
|
||||
<span
|
||||
v-if="entry.log.logger && !['ytptube', 'http_api'].includes(entry.log.logger)"
|
||||
:title="entry.log.logger"
|
||||
class="inline-block max-w-[46vw] truncate align-middle text-[11px] font-semibold text-toned sm:max-w-104"
|
||||
>[{{ entry.log.logger }}]</span
|
||||
>
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="block min-w-0 flex-1 cursor-pointer text-left"
|
||||
:aria-expanded="expandedRows.has(entry.log.id) || textWrap"
|
||||
@click="toggleRow(entry.log.id)"
|
||||
>
|
||||
<span :class="messageClass(entry.log.id)"
|
||||
>{{ entry.log.message
|
||||
}}<span v-if="exceptionSummary(entry.log)" class="text-error/90">
|
||||
: {{ exceptionSummary(entry.log) }}</span
|
||||
></span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
|
@ -284,7 +301,7 @@ const route = useRoute();
|
|||
const pageShell = requirePageShell('logs');
|
||||
|
||||
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
|
||||
const textWrap = useStorage<boolean>('logs_wrap', true);
|
||||
const textWrap = useStorage<boolean>('logs_text_wrap', false);
|
||||
const selectedLevels = useStorage<LogLevel[]>('logs_level_filter', [...LOG_LEVELS]);
|
||||
const sseController = ref<AbortController | null>(null);
|
||||
const runtimeLogLevel = ref<LogLevel | null>(null);
|
||||
|
|
@ -297,6 +314,7 @@ const loading = ref(false);
|
|||
const autoScroll = ref(true);
|
||||
const reachedEnd = ref(false);
|
||||
const detailsOpen = ref(false);
|
||||
const expandedRows = ref<Set<string>>(new Set());
|
||||
|
||||
const pageCardUi = {
|
||||
root: 'w-full min-w-0 max-w-full bg-transparent',
|
||||
|
|
@ -765,14 +783,23 @@ const logLevelBadgeClass = (level: LogLevel): string[] => [
|
|||
level === 'error' ? 'bg-error/10 text-error' : '',
|
||||
];
|
||||
|
||||
const logLineClass = (): string[] => [
|
||||
'flex-1',
|
||||
textWrap.value
|
||||
? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]'
|
||||
: 'min-w-max whitespace-pre',
|
||||
'text-default',
|
||||
const messageClass = (id: string): string[] => [
|
||||
'block text-sm text-default',
|
||||
expandedRows.value.has(id) || textWrap.value
|
||||
? 'whitespace-pre-wrap wrap-break-word'
|
||||
: 'message-collapsed md:block md:truncate',
|
||||
];
|
||||
|
||||
const toggleRow = (id: string): void => {
|
||||
const next = new Set(expandedRows.value);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
expandedRows.value = next;
|
||||
};
|
||||
|
||||
const logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
|
||||
const classes = [LOG_ROW_CLASS];
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
59
ui/app/utils/logs.ts
Normal file
59
ui/app/utils/logs.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const formatPathValue = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || null === value) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
const flattenJsonPaths = (value: unknown, prefix = ''): Array<string> => {
|
||||
if (Array.isArray(value)) {
|
||||
if (0 === value.length) {
|
||||
return prefix ? [`${prefix}: []`] : [];
|
||||
}
|
||||
|
||||
return value.flatMap((item, index) =>
|
||||
flattenJsonPaths(item, prefix ? `${prefix}.${index}` : String(index)),
|
||||
);
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (0 === entries.length) {
|
||||
return prefix ? [`${prefix}: {}`] : [];
|
||||
}
|
||||
|
||||
return entries.flatMap(([key, item]) =>
|
||||
flattenJsonPaths(item, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
return prefix ? [`${prefix}: ${formatPathValue(value)}`] : [];
|
||||
};
|
||||
|
||||
export const filterLogTextLines = (value: string, filter: string): Array<string> => {
|
||||
const query = filter.trim();
|
||||
if (!query) {
|
||||
return value.split('\n');
|
||||
}
|
||||
|
||||
const needle = query.toLowerCase();
|
||||
|
||||
if (query.includes('.')) {
|
||||
try {
|
||||
const flattened = flattenJsonPaths(JSON.parse(value) as unknown);
|
||||
const matches = flattened.filter((line) => line.toLowerCase().includes(needle));
|
||||
if (matches.length > 0) {
|
||||
return matches;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to plain line filtering for non-JSON content.
|
||||
}
|
||||
}
|
||||
|
||||
return value.split('\n').filter((line) => line.toLowerCase().includes(needle));
|
||||
};
|
||||
44
ui/tests/composables/useMode.test.ts
Normal file
44
ui/tests/composables/useMode.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
SIMPLE_PATH,
|
||||
isSimple,
|
||||
modeFromPref,
|
||||
parseMode,
|
||||
prefFromMode,
|
||||
routeTarget,
|
||||
} from '~/composables/useMode';
|
||||
|
||||
describe('useMode', () => {
|
||||
it('parse', () => {
|
||||
expect(parseMode('true')).toBe(true);
|
||||
expect(parseMode('false')).toBe(false);
|
||||
expect(parseMode(true)).toBe(true);
|
||||
expect(parseMode(false)).toBe(false);
|
||||
expect(parseMode('null')).toBeNull();
|
||||
expect(parseMode('')).toBeNull();
|
||||
});
|
||||
|
||||
it('resolve', () => {
|
||||
expect(isSimple(null, true)).toBe(true);
|
||||
expect(isSimple(null, false)).toBe(false);
|
||||
expect(isSimple(false, true)).toBe(false);
|
||||
expect(isSimple(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('map', () => {
|
||||
expect(modeFromPref(null)).toBe('default');
|
||||
expect(modeFromPref(true)).toBe('simple');
|
||||
expect(modeFromPref(false)).toBe('regular');
|
||||
expect(prefFromMode('default')).toBeNull();
|
||||
expect(prefFromMode('simple')).toBe(true);
|
||||
expect(prefFromMode('regular')).toBe(false);
|
||||
});
|
||||
|
||||
it('route', () => {
|
||||
expect(routeTarget('/', null, true)).toBe(SIMPLE_PATH);
|
||||
expect(routeTarget('/', false, true)).toBeNull();
|
||||
expect(routeTarget('/history', null, true)).toBeNull();
|
||||
expect(routeTarget(SIMPLE_PATH, null, false)).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue