refactor: improve logs view
This commit is contained in:
parent
3381ca3805
commit
19f0b7819e
6 changed files with 631 additions and 287 deletions
|
|
@ -65,6 +65,23 @@
|
||||||
backdrop-filter: blur(16px);
|
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 {
|
.play-overlay {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,7 @@
|
||||||
variant="soft"
|
variant="soft"
|
||||||
icon="i-lucide-filter"
|
icon="i-lucide-filter"
|
||||||
title="No matching lines"
|
title="No matching lines"
|
||||||
>
|
/>
|
||||||
<template #description>
|
|
||||||
<p class="text-sm text-default">
|
|
||||||
No lines match this filter: <u>{{ query }}</u>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</UAlert>
|
|
||||||
|
|
||||||
<UAlert
|
<UAlert
|
||||||
v-else-if="!hasDisplayText"
|
v-else-if="!hasDisplayText"
|
||||||
|
|
@ -52,19 +46,30 @@
|
||||||
description="The request completed successfully but did not return any visible content."
|
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">
|
<pre
|
||||||
<code
|
v-else-if="!query || filteredLineCount > 0"
|
||||||
ref="contentView"
|
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"
|
class="ytp-terminal max-h-[55vh] overflow-auto"
|
||||||
v-text="displayedText"
|
:class="wrap ? 'whitespace-pre-wrap wrap-break-word' : 'whitespace-pre'"
|
||||||
/>
|
><code v-text="displayedText" /></pre>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex w-full flex-wrap items-center justify-end gap-2">
|
<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
|
<UButton
|
||||||
type="button"
|
type="button"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
@ -119,7 +124,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const toast = useNotification();
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import { filterLogTextLines } from '~/utils/logs';
|
||||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
|
|
@ -141,9 +147,12 @@ const isLoading = ref(true);
|
||||||
const data = ref<unknown>(null);
|
const data = ref<unknown>(null);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const query = ref('');
|
const query = ref('');
|
||||||
|
const wrap = useStorage<boolean>('getinfo_wrap', false);
|
||||||
const contentView = ref<HTMLElement | null>(null);
|
const contentView = ref<HTMLElement | null>(null);
|
||||||
let latestRequestId = 0;
|
let latestRequestId = 0;
|
||||||
|
|
||||||
|
const toast = useNotification();
|
||||||
|
|
||||||
const modalUi = {
|
const modalUi = {
|
||||||
content: 'w-full sm:max-w-5xl',
|
content: 'w-full sm:max-w-5xl',
|
||||||
body: 'p-4 sm:p-5',
|
body: 'p-4 sm:p-5',
|
||||||
|
|
@ -199,22 +208,9 @@ const displayText = computed(() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const lines = computed<Array<string>>(() => {
|
const filteredLines = computed<Array<string>>(() =>
|
||||||
if (!displayText.value) {
|
filterLogTextLines(displayText.value, query.value),
|
||||||
return [];
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return displayText.value.split('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredLines = computed<Array<string>>(() => {
|
|
||||||
if (!query.value) {
|
|
||||||
return lines.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const needle = query.value.toLowerCase();
|
|
||||||
return lines.value.filter((line) => line.toLowerCase().includes(needle));
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredLineCount = computed(() => filteredLines.value.length);
|
const filteredLineCount = computed(() => filteredLines.value.length);
|
||||||
const displayedText = computed(() =>
|
const displayedText = computed(() =>
|
||||||
|
|
|
||||||
|
|
@ -10,97 +10,142 @@
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div v-if="log" class="space-y-5">
|
<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="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
<StatCard
|
||||||
<UBadge
|
label="Level"
|
||||||
:color="LOG_LEVEL_COLOR[getLogLevel(log.level)]"
|
:value="getLogLevel(log.level)"
|
||||||
variant="soft"
|
:icon="LOG_LEVEL_ICON[getLogLevel(log.level)]"
|
||||||
size="sm"
|
:color="LOG_LEVEL_COLOR[getLogLevel(log.level)]"
|
||||||
class="uppercase"
|
/>
|
||||||
>
|
<StatCard v-if="log.logger" label="Logger" :value="log.logger" icon="i-lucide-tag" />
|
||||||
<UIcon :name="LOG_LEVEL_ICON[getLogLevel(log.level)]" class="mr-1 size-3.5" />
|
<StatCard
|
||||||
{{ getLogLevel(log.level) }}
|
label="Date"
|
||||||
</UBadge>
|
:value="moment(log.datetime).fromNow()"
|
||||||
<UBadge
|
icon="i-lucide-clock"
|
||||||
v-if="log.logger"
|
: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"
|
color="neutral"
|
||||||
variant="soft"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="max-w-full min-w-0"
|
icon="i-lucide-copy"
|
||||||
:title="log.logger"
|
trailing-icon="i-lucide-chevron-down"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-tag" class="mr-1 size-3.5" />
|
Copy
|
||||||
<span class="min-w-0 max-w-full truncate">{{ log.logger }}</span>
|
</UButton>
|
||||||
</UBadge>
|
</UDropdownMenu>
|
||||||
<span class="text-xs text-toned">{{ logTimeTitle(log.datetime) }}</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex shrink-0 flex-wrap justify-end gap-2">
|
<UAlert
|
||||||
<UDropdownMenu :items="copyMenuItems" :content="{ align: 'end' }" :modal="false">
|
:color="LOG_LEVEL_COLOR[getLogLevel(log.level)]"
|
||||||
<UButton
|
variant="soft"
|
||||||
color="neutral"
|
:icon="LOG_LEVEL_ICON[getLogLevel(log.level)]"
|
||||||
variant="outline"
|
title=""
|
||||||
size="xs"
|
class="min-w-0"
|
||||||
icon="i-lucide-copy"
|
>
|
||||||
trailing-icon="i-lucide-chevron-down"
|
<template #description>
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</UButton>
|
|
||||||
</UDropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0 space-y-2 sm:col-span-2">
|
|
||||||
<p class="wrap-break-word w-full font-mono text-sm text-default">
|
<p class="wrap-break-word w-full font-mono text-sm text-default">
|
||||||
{{ log.message }}
|
{{ log.message }}
|
||||||
</p>
|
</p>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
<UAlert
|
<UAlert
|
||||||
v-if="exceptionSummary(log)"
|
v-if="exceptionSummary(log)"
|
||||||
color="error"
|
color="error"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
icon="i-lucide-badge-alert"
|
icon="i-lucide-badge-alert"
|
||||||
:title="exceptionSummary(log)"
|
:title="exceptionSummary(log)"
|
||||||
class="w-full"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section v-if="log.exception" class="space-y-2">
|
<section v-if="log.exception" class="space-y-3">
|
||||||
<button
|
<button
|
||||||
type="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"
|
@click="exceptionOpen = !exceptionOpen"
|
||||||
>
|
>
|
||||||
<UIcon
|
<div class="flex items-center gap-3">
|
||||||
name="i-lucide-chevron-right"
|
<span
|
||||||
:class="['size-4 transition-transform', exceptionOpen ? 'rotate-90' : '']"
|
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 text-error" />
|
<UIcon name="i-lucide-bug" class="size-4" />
|
||||||
Exception
|
</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>
|
</button>
|
||||||
|
|
||||||
<pre
|
<pre
|
||||||
v-if="exceptionOpen"
|
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"
|
class="ytp-terminal max-h-96 overflow-auto"
|
||||||
>{{ exceptionText(log) }}</pre
|
:class="wrapException ? 'whitespace-pre-wrap wrap-break-word' : 'whitespace-pre'"
|
||||||
>
|
><code>{{ exceptionText(log) }}</code></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="detailRows(log).length > 0" class="space-y-2">
|
<section v-if="detailRows.length > 0" class="space-y-3">
|
||||||
<button
|
<button
|
||||||
type="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"
|
@click="sourceOpen = !sourceOpen"
|
||||||
>
|
>
|
||||||
<UIcon
|
<div class="flex items-center gap-3">
|
||||||
name="i-lucide-chevron-right"
|
<span
|
||||||
:class="['size-4 transition-transform', sourceOpen ? 'rotate-90' : '']"
|
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 text-info" />
|
<UIcon name="i-lucide-file-code" class="size-4" />
|
||||||
Source
|
</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>
|
</button>
|
||||||
|
|
||||||
<dl v-if="sourceOpen" class="grid gap-2 sm:grid-cols-2">
|
<dl v-if="sourceOpen" class="grid gap-2 sm:grid-cols-2">
|
||||||
<div
|
<div
|
||||||
v-for="row in detailRows(log)"
|
v-for="row in detailRows"
|
||||||
:key="row.label"
|
:key="row.label"
|
||||||
class="rounded-sm border border-default bg-elevated/40 p-3"
|
class="rounded-sm border border-default bg-elevated/40 p-3"
|
||||||
>
|
>
|
||||||
|
|
@ -115,22 +160,66 @@
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="fieldRows(log).length > 0" class="space-y-2">
|
<section v-if="fieldRows.length > 0" class="space-y-3">
|
||||||
<button
|
<button
|
||||||
type="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"
|
@click="fieldsOpen = !fieldsOpen"
|
||||||
>
|
>
|
||||||
<UIcon
|
<div class="flex items-center gap-3">
|
||||||
name="i-lucide-chevron-right"
|
<span
|
||||||
:class="['size-4 transition-transform', fieldsOpen ? 'rotate-90' : '']"
|
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 text-primary" />
|
<UIcon name="i-lucide-tags" class="size-4" />
|
||||||
Fields
|
</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>
|
</button>
|
||||||
|
|
||||||
<div v-if="fieldsOpen" class="space-y-2">
|
<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
|
<div
|
||||||
v-for="field in fieldRows(log)"
|
v-for="field in visibleFieldRows"
|
||||||
:key="field.key"
|
:key="field.key"
|
||||||
class="rounded-sm border border-default bg-elevated/40"
|
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 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">
|
<div class="mb-3 flex items-center justify-between gap-3">
|
||||||
<UInput
|
<div v-if="field.kind !== 'scalar'" class="w-full">
|
||||||
v-if="field.kind !== 'scalar'"
|
<UInput
|
||||||
:model-value="fieldFilter(field.key)"
|
v-model="fieldFilters[field.key]"
|
||||||
type="search"
|
type="search"
|
||||||
icon="i-lucide-filter"
|
icon="i-lucide-filter"
|
||||||
placeholder="Filter field lines"
|
placeholder="Filter field lines"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@update:model-value="setFieldFilter(field.key, $event)"
|
/>
|
||||||
/>
|
</div>
|
||||||
<UButton
|
<UButton
|
||||||
size="xs"
|
size="sm"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon="i-lucide-copy"
|
icon="i-lucide-copy"
|
||||||
@click="copyFieldValue(field)"
|
@click="copyText(displayedFieldValue(field), true)"
|
||||||
>
|
/>
|
||||||
Copy
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</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
|
<pre
|
||||||
v-if="field.kind === 'json'"
|
v-if="
|
||||||
class="max-h-96 overflow-auto rounded-sm border border-default bg-elevated/50 p-3 text-xs whitespace-pre-wrap text-default"
|
field.kind === 'json' &&
|
||||||
>{{ displayedFieldValue(field) }}</pre
|
(!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
|
<pre
|
||||||
v-else-if="field.kind === 'text'"
|
v-else-if="
|
||||||
class="max-h-96 overflow-auto rounded-sm border border-default bg-elevated/50 p-3 text-xs whitespace-pre-wrap text-default"
|
field.kind === 'text' &&
|
||||||
>{{ displayedFieldValue(field) }}</pre
|
(!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">
|
<p v-else class="wrap-break-word font-mono text-xs text-default">
|
||||||
{{ field.value }}
|
{{ field.value }}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -197,44 +299,70 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="space-y-2">
|
<section class="space-y-3">
|
||||||
<button
|
<button
|
||||||
type="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"
|
@click="rawJsonOpen = !rawJsonOpen"
|
||||||
>
|
>
|
||||||
<UIcon
|
<div class="flex items-center gap-3">
|
||||||
name="i-lucide-chevron-right"
|
<span
|
||||||
:class="['size-4 transition-transform', rawJsonOpen ? 'rotate-90' : '']"
|
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 text-toned" />
|
<UIcon name="i-lucide-braces" class="size-4" />
|
||||||
RAW DATA
|
</span>
|
||||||
</button>
|
<p class="text-base font-semibold text-highlighted">Raw Data</p>
|
||||||
<div v-if="rawJsonOpen" class="space-y-3">
|
</div>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center gap-2" @click.stop>
|
||||||
<UInput
|
|
||||||
v-model="rawJsonFilter"
|
|
||||||
type="search"
|
|
||||||
icon="i-lucide-filter"
|
|
||||||
placeholder="Filter lines"
|
|
||||||
size="sm"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<UButton
|
<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"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon="i-lucide-copy"
|
icon="i-lucide-copy"
|
||||||
@click="log && copyText(logRaw(log))"
|
@click="copyText(displayedRawJson, true)"
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-chevron-right"
|
||||||
|
:class="['size-4 text-toned transition-transform', rawJsonOpen ? 'rotate-90' : '']"
|
||||||
|
/>
|
||||||
</div>
|
</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
|
<pre
|
||||||
class="max-h-96 overflow-auto rounded-sm border border-default bg-elevated/50 p-3 text-xs whitespace-pre-wrap text-default"
|
v-if="!rawJsonFilter || filteredRawJsonLineCount > 0"
|
||||||
>{{ filteredRawJson }}</pre
|
class="ytp-terminal max-h-96 overflow-auto"
|
||||||
>
|
:class="wrapRaw ? 'whitespace-pre-wrap wrap-break-word' : 'whitespace-pre'"
|
||||||
</div>
|
><code>{{ displayedRawJson }}</code></pre>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -244,6 +372,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { useStorage } from '@vueuse/core';
|
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 type { log_line } from '~/types/logs';
|
||||||
import { copyText } from '~/utils';
|
import { copyText } from '~/utils';
|
||||||
|
|
||||||
|
|
@ -292,10 +423,15 @@ const LOG_LEVEL_ICON: Record<LogLevel, string> = {
|
||||||
const exceptionOpen = useStorage<boolean>('logs_exception_open', false);
|
const exceptionOpen = useStorage<boolean>('logs_exception_open', false);
|
||||||
const fieldsOpen = useStorage<boolean>('logs_fields_open', true);
|
const fieldsOpen = useStorage<boolean>('logs_fields_open', true);
|
||||||
const rawJsonOpen = useStorage<boolean>('logs_raw_json_open', false);
|
const rawJsonOpen = useStorage<boolean>('logs_raw_json_open', false);
|
||||||
const rawJsonFilter = ref('');
|
|
||||||
const sourceOpen = useStorage<boolean>('logs_source_open', true);
|
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 fieldOpenState = ref<Record<string, boolean>>({});
|
||||||
const fieldFilters = ref<Record<string, string>>({});
|
const fieldFilters = ref<Record<string, string>>({});
|
||||||
|
const fieldsQuery = ref<string>('');
|
||||||
|
|
||||||
const getLogLevel = (level: string): LogLevel => {
|
const getLogLevel = (level: string): LogLevel => {
|
||||||
switch (level.toLowerCase()) {
|
switch (level.toLowerCase()) {
|
||||||
|
|
@ -329,8 +465,19 @@ const exceptionSummary = (log: log_line): string => {
|
||||||
const exceptionText = (log: log_line): string =>
|
const exceptionText = (log: log_line): string =>
|
||||||
log.exception ? JSON.stringify(log.exception, null, 2) : '';
|
log.exception ? JSON.stringify(log.exception, null, 2) : '';
|
||||||
|
|
||||||
const logTimeTitle = (value?: string): string =>
|
const rawJson = computed(() => (props.log ? logRaw(props.log) : ''));
|
||||||
value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp';
|
|
||||||
|
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(() => [
|
const copyMenuItems = computed(() => [
|
||||||
[
|
[
|
||||||
|
|
@ -348,22 +495,20 @@ const copyMenuItems = computed(() => [
|
||||||
icon: 'i-lucide-braces',
|
icon: 'i-lucide-braces',
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
if (props.log) {
|
if (props.log) {
|
||||||
copyText(logRaw(props.log));
|
copyText(rawJson.value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filteredRawJson = computed(() => {
|
const filteredRawJsonLines = computed<Array<string>>(() =>
|
||||||
const raw = props.log ? logRaw(props.log) : '';
|
filterLogTextLines(rawJson.value, rawJsonFilter.value),
|
||||||
const query = rawJsonFilter.value.toLowerCase();
|
);
|
||||||
if (!query) return raw;
|
const filteredRawJsonLineCount = computed<number>(() => filteredRawJsonLines.value.length);
|
||||||
return raw
|
const displayedRawJson = computed<string>(() =>
|
||||||
.split('\n')
|
rawJsonFilter.value ? filteredRawJsonLines.value.join('\n') : rawJson.value,
|
||||||
.filter((line) => line.toLowerCase().includes(query))
|
);
|
||||||
.join('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDetailValue = (value: unknown): string => {
|
const formatDetailValue = (value: unknown): string => {
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
|
|
@ -400,79 +545,31 @@ const formatNameId = (name: unknown, id: unknown): string => {
|
||||||
return nameValue || idValue;
|
return nameValue || idValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailRows = (log: log_line): DetailRow[] =>
|
const detailRows = computed((): DetailRow[] => {
|
||||||
compactRows([
|
if (!props.log) return [];
|
||||||
{ label: 'File', value: log.source?.file, icon: 'i-lucide-file' },
|
return compactRows([
|
||||||
{ label: 'Line', value: log.source?.line, icon: 'i-lucide-hash' },
|
{ label: 'File', value: props.log.source?.file, icon: 'i-lucide-file' },
|
||||||
{ label: 'Function', value: log.source?.function, icon: 'i-lucide-code-2' },
|
{ label: 'Line', value: props.log.source?.line, icon: 'i-lucide-hash' },
|
||||||
{ label: 'Module', value: log.source?.module, icon: 'i-lucide-box' },
|
{ label: 'Function', value: props.log.source?.function, icon: 'i-lucide-code-2' },
|
||||||
{ label: 'Path', value: log.source?.path, icon: 'i-lucide-folder-tree' },
|
{ 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',
|
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',
|
icon: 'i-lucide-cpu',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Thread / ID',
|
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',
|
icon: 'i-lucide-git-branch',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
const fieldRows = (log: log_line): LogFieldRow[] => {
|
const formatFieldValue = (value: unknown): string => {
|
||||||
if (!log.fields) return [];
|
if (typeof value === 'string') return value;
|
||||||
const rows: LogFieldRow[] = [];
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
for (const [key, rawValue] of Object.entries(log.fields)) {
|
return JSON.stringify(value, null, 2);
|
||||||
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 isJsonLike = (value: string): boolean => {
|
const isJsonLike = (value: string): boolean => {
|
||||||
|
|
@ -499,11 +596,86 @@ const parseJsonContainerString = (value: string): Record<string, unknown> | unkn
|
||||||
return null;
|
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 => {
|
const visibleFieldRows = computed((): LogFieldRow[] => {
|
||||||
if (typeof value === 'string') return value;
|
const query = fieldsQuery.value.trim().toLowerCase();
|
||||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
if (!query) {
|
||||||
return JSON.stringify(value, null, 2);
|
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>
|
</script>
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
@ -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>
|
<template>
|
||||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
<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">
|
<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 class="overflow-hidden rounded-sm border border-default bg-default shadow-sm">
|
||||||
<div
|
<div
|
||||||
ref="logContainer"
|
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"
|
@scroll.passive="handleScroll"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -151,53 +168,53 @@
|
||||||
:class="logRowClass(entry, index)"
|
:class="logRowClass(entry, index)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="[
|
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"
|
||||||
'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',
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<p :class="logLineClass()">
|
<span
|
||||||
<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"
|
||||||
class="inline-flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 align-middle"
|
>
|
||||||
>
|
<UTooltip :text="logTimeTitle(entry.log.datetime)">
|
||||||
<UTooltip :text="logTimeTitle(entry.log.datetime)">
|
<span class="inline text-[11px] font-semibold text-toned cursor-pointer">
|
||||||
<span class="inline text-[11px] font-semibold text-toned cursor-pointer">
|
{{ logTimeLabel(entry.log.datetime) }}
|
||||||
{{ logTimeLabel(entry.log.datetime) }}
|
|
||||||
</span>
|
|
||||||
</UTooltip>
|
|
||||||
<UButton
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
icon="i-lucide-panel-right-open"
|
|
||||||
aria-label="Open log details"
|
|
||||||
class="inline-flex align-[-0.2em] opacity-70 hover:opacity-100"
|
|
||||||
@click="openLogDetails(entry.log)"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
:class="logLevelBadgeClass(getLogLevel(entry.log.level))"
|
|
||||||
@click="openLogDetails(entry.log)"
|
|
||||||
>
|
|
||||||
<UIcon
|
|
||||||
:name="LOG_LEVEL_ICON[getLogLevel(entry.log.level)]"
|
|
||||||
class="size-3"
|
|
||||||
/>
|
|
||||||
{{ getLogLevel(entry.log.level) }}
|
|
||||||
</span>
|
</span>
|
||||||
<span
|
</UTooltip>
|
||||||
v-if="
|
<UButton
|
||||||
entry.log.logger && !['ytptube', 'http_api'].includes(entry.log.logger)
|
color="neutral"
|
||||||
"
|
variant="ghost"
|
||||||
:title="entry.log.logger"
|
size="xs"
|
||||||
class="inline-block max-w-[46vw] truncate align-middle text-[11px] font-semibold text-toned sm:max-w-104"
|
icon="i-lucide-panel-right-open"
|
||||||
>[{{ entry.log.logger }}]</span
|
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>
|
||||||
<span class="ml-2">{{ entry.log.message }}</span>
|
<span
|
||||||
<span v-if="exceptionSummary(entry.log)" class="ml-1 text-error/90">
|
v-if="entry.log.logger && !['ytptube', 'http_api'].includes(entry.log.logger)"
|
||||||
: {{ exceptionSummary(entry.log) }}
|
:title="entry.log.logger"
|
||||||
</span>
|
class="inline-block max-w-[46vw] truncate align-middle text-[11px] font-semibold text-toned sm:max-w-104"
|
||||||
</p>
|
>[{{ 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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -284,7 +301,7 @@ const route = useRoute();
|
||||||
const pageShell = requirePageShell('logs');
|
const pageShell = requirePageShell('logs');
|
||||||
|
|
||||||
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
|
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
|
||||||
const textWrap = useStorage<boolean>('logs_wrap', true);
|
const textWrap = useStorage<boolean>('logs_text_wrap', false);
|
||||||
const selectedLevels = useStorage<LogLevel[]>('logs_level_filter', [...LOG_LEVELS]);
|
const selectedLevels = useStorage<LogLevel[]>('logs_level_filter', [...LOG_LEVELS]);
|
||||||
const sseController = ref<AbortController | null>(null);
|
const sseController = ref<AbortController | null>(null);
|
||||||
const runtimeLogLevel = ref<LogLevel | null>(null);
|
const runtimeLogLevel = ref<LogLevel | null>(null);
|
||||||
|
|
@ -297,6 +314,7 @@ const loading = ref(false);
|
||||||
const autoScroll = ref(true);
|
const autoScroll = ref(true);
|
||||||
const reachedEnd = ref(false);
|
const reachedEnd = ref(false);
|
||||||
const detailsOpen = ref(false);
|
const detailsOpen = ref(false);
|
||||||
|
const expandedRows = ref<Set<string>>(new Set());
|
||||||
|
|
||||||
const pageCardUi = {
|
const pageCardUi = {
|
||||||
root: 'w-full min-w-0 max-w-full bg-transparent',
|
root: 'w-full min-w-0 max-w-full bg-transparent',
|
||||||
|
|
@ -765,14 +783,23 @@ const logLevelBadgeClass = (level: LogLevel): string[] => [
|
||||||
level === 'error' ? 'bg-error/10 text-error' : '',
|
level === 'error' ? 'bg-error/10 text-error' : '',
|
||||||
];
|
];
|
||||||
|
|
||||||
const logLineClass = (): string[] => [
|
const messageClass = (id: string): string[] => [
|
||||||
'flex-1',
|
'block text-sm text-default',
|
||||||
textWrap.value
|
expandedRows.value.has(id) || textWrap.value
|
||||||
? 'min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]'
|
? 'whitespace-pre-wrap wrap-break-word'
|
||||||
: 'min-w-max whitespace-pre',
|
: 'message-collapsed md:block md:truncate',
|
||||||
'text-default',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
|
||||||
const classes = [LOG_ROW_CLASS];
|
const classes = [LOG_ROW_CLASS];
|
||||||
|
|
||||||
|
|
|
||||||
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));
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue