diff --git a/ui/app/assets/css/tailwind.css b/ui/app/assets/css/tailwind.css index b8f550b5..786d1cdf 100644 --- a/ui/app/assets/css/tailwind.css +++ b/ui/app/assets/css/tailwind.css @@ -151,3 +151,19 @@ --ui-container: 96rem; --ui-header-height: 4.25rem; } + +html:not(.no-page-anim) .page-enter-active { + transition: + opacity 0.25s ease, + transform 0.25s ease; +} +html:not(.no-page-anim) .page-leave-active { + transition: opacity 0.1s ease; +} +html:not(.no-page-anim) .page-enter-from { + opacity: 0; + transform: translateY(-4px); +} +html:not(.no-page-anim) .page-leave-to { + opacity: 0; +} diff --git a/ui/app/components/SettingsPanel.vue b/ui/app/components/SettingsPanel.vue index 4f613b22..b94ff349 100644 --- a/ui/app/components/SettingsPanel.vue +++ b/ui/app/components/SettingsPanel.vue @@ -45,6 +45,15 @@ :label="simpleMode ? 'Simple View' : 'Regular View'" description="The simple view is ideal for non-technical users and mobile devices." /> + + @@ -307,6 +316,7 @@ const show_popover = useStorage('show_popover', true); const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1'); const separator = useStorage('url_separator', separators[0]?.value ?? ','); const simpleMode = useStorage('simple_mode', config.app.simple_mode || false); +const page_anims = useStorage('page_anims', true); const queue_auto_refresh = useStorage('queue_auto_refresh', true); const queue_auto_refresh_delay = useStorage('queue_auto_refresh_delay', 10000); const isSecureContext = ref(false); diff --git a/ui/app/layouts/default.vue b/ui/app/layouts/default.vue index 1656db34..a23a84d5 100644 --- a/ui/app/layouts/default.vue +++ b/ui/app/layouts/default.vue @@ -568,6 +568,7 @@ const loadedImage = ref(); const loadingImage = ref(false); const bg_enable = useStorage('random_bg', true); const bg_opacity = useStorage('random_bg_opacity', 0.95); +const page_anims = useStorage('page_anims', true); const app_shutdown = ref(false); const simpleMode = useStorage('simple_mode', config.app.simple_mode || false); const show_settings = ref(false); @@ -1029,6 +1030,18 @@ watch(bg_opacity, () => { syncOpacity(); }); +watch( + page_anims, + (val) => { + if (val) { + document.documentElement.classList.remove('no-page-anim'); + } else { + document.documentElement.classList.add('no-page-anim'); + } + }, + { immediate: true }, +); + watch(loadedImage, () => { if (false === bg_enable.value) { return; diff --git a/ui/app/pages/logs.vue b/ui/app/pages/logs.vue index db9b0fa4..bd66e077 100644 --- a/ui/app/pages/logs.vue +++ b/ui/app/pages/logs.vue @@ -255,24 +255,17 @@ - - Message - - - JSON - + + + Copy + + @@ -354,21 +347,74 @@ Fields - + - - - {{ row.label }} - - {{ row.value }} + + {{ field.label }} + + + + {{ field.preview }} + + + + + + + + + + Copy + + + + {{ displayedFieldValue(field) }} + {{ displayedFieldValue(field) }} + + {{ field.value }} + + - + @@ -419,6 +465,13 @@ type DetailRow = { value: string; icon: string; }; +type LogFieldRow = { + key: string; + label: string; + value: string; + preview: string; + kind: 'scalar' | 'text' | 'json'; +}; type LevelFilterItem = { label: string; value: LogLevel; @@ -458,6 +511,8 @@ const exceptionOpen = useStorage('logs_exception_open', false); const fieldsOpen = useStorage('logs_fields_open', true); const rawJsonOpen = useStorage('logs_raw_json_open', false); const sourceOpen = useStorage('logs_source_open', true); +const fieldOpenState = ref>({}); +const fieldFilters = ref>({}); const selectedLevels = useStorage('logs_level_filter', [...LOG_LEVELS]); const sseController = ref(null); const runtimeLogLevel = ref(null); @@ -482,6 +537,29 @@ const detailsModalUi = { body: 'max-h-[75vh] overflow-y-auto', }; +const copyMenuItems = computed(() => [ + [ + { + label: 'Copy Message', + icon: 'i-lucide-message-square-text', + onSelect: () => { + if (selectedLog.value) { + copyText(selectedLog.value.message); + } + }, + }, + { + label: 'Copy JSON', + icon: 'i-lucide-braces', + onSelect: () => { + if (selectedLog.value) { + copyText(logRaw(selectedLog.value)); + } + }, + }, + ], +]); + const query = ref( (() => { const filter = route.query.filter ?? ''; @@ -1033,21 +1111,89 @@ const detailRows = (log: log_line): DetailRow[] => }, ]); -const fieldRows = (log: log_line): DetailRow[] => - compactRows( - Object.entries(log.fields ?? {}).map(([label, value]) => ({ - label, +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, - icon: 'i-lucide-tag', - })), - ); - -const copyLogMessage = (log: log_line): void => { - copyText(log.message); + preview: formatFieldValue(rawValue).replaceAll('\n', ' '), + kind, + }); + } + return rows; }; -const copyLogRaw = (log: log_line): void => { - copyText(logRaw(log)); +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 trimmed = value.trim(); + return ( + ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) && + trimmed.length > 1 + ); +}; + +const isJsonContainer = (value: unknown): value is Record | unknown[] => + Array.isArray(value) || (!!value && typeof value === 'object'); + +const parseJsonContainerString = (value: string): Record | unknown[] | null => { + if (!isJsonLike(value)) return null; + try { + const parsed = JSON.parse(value) as unknown; + if (Array.isArray(parsed)) return parsed; + if (parsed && typeof parsed === 'object') return parsed as Record; + } catch { + return null; + } + return null; +}; + +const normalizeFieldLabel = (key: string) => key.replaceAll('_', ' '); + +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); }; onMounted(async () => { diff --git a/ui/nuxt.config.ts b/ui/nuxt.config.ts index 7742ffc1..dbfd898f 100644 --- a/ui/nuxt.config.ts +++ b/ui/nuxt.config.ts @@ -57,7 +57,7 @@ export default defineNuxtConfig({ { rel: 'apple-touch-startup-image', href: 'images/logo.png' }, ], }, - pageTransition: { name: 'page' }, + pageTransition: { name: 'page', mode: 'out-in' }, }, modules: ['@nuxt/ui', '@vueuse/nuxt', '@nuxt/eslint'], icon: {
{{ displayedFieldValue(field) }}
+ {{ field.value }} +