Files
MoviePilot-Frontend/src/views/system/LoggingView.vue

1009 lines
24 KiB
Vue

<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { useBackground } from '@/composables/useBackground'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
import { useDisplay } from 'vuetify'
type LogEntry = {
id: number
raw: string
level: string
appName: string
timestamp: string
timestampMs: number | null
secondKey: string
secondDisplay: string
timeDisplay: string
displayLevel: string
source: string
message: string
structured: boolean
}
type LogGroup = {
id: string
level: string
secondKey: string
secondDisplay: string
items: LogEntry[]
lastTimestampMs: number | null
}
type ParsedLog = {
level: string
appName: string
timestamp: string
source: string
message: string
structured: boolean
}
const props = defineProps<{
logfile: string
}>()
const { t } = useI18n()
const theme = useTheme()
const display = useDisplay()
const { useSSE } = useBackground()
const DEFAULT_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
const MAX_LOG_LINES = 600
const FLUSH_DELAY = 80
const GROUP_GAP_MS = 100
const SCROLL_BOTTOM_THRESHOLD = 32
const ANSI_PATTERN = /\u001B\[[0-9;]*m/g
const TIMESTAMP_PATTERN = /\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?(?:,\d{3})?)?/
const parsedLogs = ref<LogEntry[]>([])
const logViewportRef = ref<HTMLElement | null>(null)
const isMounted = ref(false)
const followTail = ref(true)
const isStreamPaused = ref(false)
const searchQuery = ref<string | null>('')
const selectedLevel = ref('ALL')
const pendingLogCount = ref(0)
let timeoutId: number | null = null
let mountTimerId: number | null = null
let logSequence = 0
const buffer: string[] = []
const listenerId = `logging-${props.logfile}`
const logColorMap: Record<string, string> = {
TRACE: 'secondary',
DEBUG: 'secondary',
INFO: 'success',
WARNING: 'warning',
ERROR: 'error',
CRITICAL: 'error',
}
const isDarkTheme = computed(() => theme.global.current.value.dark)
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
const normalizedSearchQuery = computed(() => (searchQuery.value ?? '').trim().toLowerCase())
const { availableHeight } = useAvailableHeight(96, 320)
const loggingViewStyle = computed(() => ({
blockSize: display.mdAndUp.value ? `${availableHeight.value}px` : '100%',
}))
const levelOptions = computed(() => {
const extraLevels = parsedLogs.value.map(item => item.level).filter(level => level && !DEFAULT_LEVELS.includes(level))
return ['ALL', ...DEFAULT_LEVELS, ...new Set(extraLevels)]
})
const groupedLogs = computed(() => {
const groups: LogGroup[] = []
for (const item of parsedLogs.value) {
const lastGroup = groups.at(-1)
if (lastGroup && canMergeIntoGroup(lastGroup, item)) {
lastGroup.items.push(item)
if (item.timestampMs !== null) {
lastGroup.lastTimestampMs = item.timestampMs
}
continue
}
groups.push({
id: `${item.secondKey || 'log'}-${item.level || 'plain'}-${item.id}`,
level: item.level,
secondKey: item.secondKey,
secondDisplay: item.secondDisplay,
items: [item],
lastTimestampMs: item.timestampMs,
})
}
return groups
})
const filteredGroups = computed(() => {
return groupedLogs.value
.map(group => ({
...group,
items: group.items.filter(matchesLogFilter),
}))
.filter(group => group.items.length > 0)
})
const visibleLogCount = computed(() => {
return filteredGroups.value.reduce((count, group) => count + group.items.length, 0)
})
const lastVisibleLogId = computed(() => {
return filteredGroups.value.at(-1)?.items.at(-1)?.id ?? 0
})
function normalizeLevel(level: string) {
const normalizedLevel = level.trim().replace(/:$/, '').toUpperCase()
if (normalizedLevel === 'WARN') {
return 'WARNING'
}
if (normalizedLevel === 'FATAL') {
return 'CRITICAL'
}
return normalizedLevel
}
function stripAnsi(text: string) {
return text.replace(ANSI_PATTERN, '')
}
function extractTimestamp(text: string) {
return text.match(TIMESTAMP_PATTERN)?.[0] ?? ''
}
function getTimestampMs(timestamp: string) {
if (!timestamp) {
return null
}
const normalizedTimestamp = timestamp.replace(' ', 'T').replace(',', '.')
const parsedTimestamp = Date.parse(normalizedTimestamp)
return Number.isNaN(parsedTimestamp) ? null : parsedTimestamp
}
function getSecondKey(timestamp: string) {
if (!timestamp) {
return ''
}
return timestamp.replace('T', ' ').slice(0, 19)
}
function getSecondDisplay(secondKey: string) {
return secondKey ? secondKey.replaceAll('-', '/') : ''
}
function getTimeDisplay(timestamp: string) {
if (!timestamp) {
return ''
}
return timestamp.split(' ').at(-1) ?? timestamp
}
function extractMessage(text: string) {
return (
text
.split(/\s+-\s+/)
.slice(1)
.join(' - ') || text
)
}
function createLogEntry(raw: string, parsed?: ParsedLog | null): LogEntry {
const level = parsed?.level ?? ''
const appName = parsed?.appName ?? ''
const timestamp = parsed?.timestamp ?? extractTimestamp(raw)
const secondKey = getSecondKey(timestamp)
return {
id: ++logSequence,
raw,
level,
appName,
timestamp,
timestampMs: getTimestampMs(timestamp),
secondKey,
secondDisplay: getSecondDisplay(secondKey),
timeDisplay: getTimeDisplay(timestamp),
displayLevel: `${level || 'LOG'}:`,
source: parsed?.source ?? '',
message: parsed?.message ?? raw,
structured: parsed?.structured ?? false,
}
}
function parsePythonStyleLog(raw: string): ParsedLog | null {
const match = raw.match(/^([A-Za-z]+):\s+(.*)$/)
if (!match) {
return null
}
const [, rawLevel, remainder] = match
const level = normalizeLevel(rawLevel)
if (!DEFAULT_LEVELS.includes(level)) {
return null
}
const body = remainder.trim()
const bodyMatch = body.match(
/^(?:\[([^\]]+)\]\s+)?(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:,\d{3})?)\s+([^\s]+)\s+-\s+(.*)$/,
)
return {
level,
appName: bodyMatch?.[1] ?? '',
timestamp: bodyMatch?.[2] ?? extractTimestamp(body),
source: bodyMatch?.[3] ?? '',
message: bodyMatch?.[4] ?? extractMessage(body),
structured: Boolean(bodyMatch),
}
}
function parseBracketStyleLog(raw: string): ParsedLog | null {
const match = raw.match(/^【([^】]+)】\s*(.*)$/)
if (!match) {
return null
}
const [, rawLevel, remainder] = match
const level = normalizeLevel(rawLevel)
const body = remainder.trim()
const bodyMatch = body.match(
/^(?:\[([^\]]+)\]\s+)?(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:,\d{3})?)\s+([^\s]+)\s+-\s+(.*)$/,
)
return {
level,
appName: bodyMatch?.[1] ?? '',
timestamp: bodyMatch?.[2] ?? extractTimestamp(body),
source: bodyMatch?.[3] ?? '',
message: bodyMatch?.[4] ?? extractMessage(body),
structured: Boolean(bodyMatch),
}
}
function parseTimestampFirstLog(raw: string): ParsedLog | null {
const match = raw.match(new RegExp(`^(${TIMESTAMP_PATTERN.source})\\s+\\[?([A-Za-z]+)\\]?\\s*(.*)$`))
if (!match) {
return null
}
const [, timestamp, rawLevel, remainder] = match
const level = normalizeLevel(rawLevel)
if (!DEFAULT_LEVELS.includes(level)) {
return null
}
const body = remainder.trim()
const bodyMatch = body.match(/^(?:\[([^\]]+)\]\s+)?([^\s]+)\s+-\s+(.*)$/)
return {
level,
appName: bodyMatch?.[1] ?? '',
timestamp,
source: bodyMatch?.[2] ?? '',
message: bodyMatch?.[3] ?? extractMessage(body),
structured: Boolean(bodyMatch),
}
}
function parseInlineLevelLog(raw: string): ParsedLog | null {
const match = raw.match(/^\[?([A-Za-z]+)\]?:?\s+(.*)$/)
if (!match) {
return null
}
const [, rawLevel, remainder] = match
const level = normalizeLevel(rawLevel)
if (!DEFAULT_LEVELS.includes(level)) {
return null
}
const body = remainder.trim()
const timestamp = extractTimestamp(body)
const bodyWithoutTimestamp = timestamp ? body.replace(timestamp, '').trim() : body
const bodyMatch = bodyWithoutTimestamp.match(/^(?:\[([^\]]+)\]\s+)?([^\s]+)\s+-\s+(.*)$/)
return {
level,
appName: bodyMatch?.[1] ?? '',
timestamp,
source: bodyMatch?.[2] ?? '',
message: bodyMatch?.[3] ?? extractMessage(bodyWithoutTimestamp),
structured: Boolean(bodyMatch),
}
}
function parseLogLine(log: string): LogEntry {
const raw = stripAnsi(log).replace(/\r/g, '').trimEnd()
const parsed =
parsePythonStyleLog(raw) ?? parseBracketStyleLog(raw) ?? parseTimestampFirstLog(raw) ?? parseInlineLevelLog(raw)
return createLogEntry(raw, parsed)
}
function matchesLogFilter(item: LogEntry) {
const matchesLevel = selectedLevel.value === 'ALL' || item.level === selectedLevel.value
if (!matchesLevel) {
return false
}
if (!normalizedSearchQuery.value) {
return true
}
return [item.raw, item.level, item.appName, item.timestamp, item.source, item.message]
.join(' ')
.toLowerCase()
.includes(normalizedSearchQuery.value)
}
function canMergeIntoGroup(group: LogGroup, item: LogEntry) {
if (!group.secondKey || !item.secondKey) {
return false
}
if (group.secondKey !== item.secondKey || group.level !== item.level) {
return false
}
if (group.lastTimestampMs !== null && item.timestampMs !== null) {
return item.timestampMs - group.lastTimestampMs <= GROUP_GAP_MS
}
return true
}
function isNearBottom() {
if (!logViewportRef.value) {
return true
}
const { scrollTop, scrollHeight, clientHeight } = logViewportRef.value
return scrollHeight - scrollTop - clientHeight <= SCROLL_BOTTOM_THRESHOLD
}
function scrollToBottom(behavior: ScrollBehavior = 'auto') {
if (!logViewportRef.value) {
return
}
logViewportRef.value.scrollTo({
top: logViewportRef.value.scrollHeight,
behavior,
})
}
function enableFollow(behavior: ScrollBehavior = 'auto') {
followTail.value = true
pendingLogCount.value = 0
nextTick(() => {
scrollToBottom(behavior)
})
}
function flushBuffer() {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
if (buffer.length === 0) {
return
}
const incomingLogs = buffer
.flatMap(item => item.split(/\r?\n/))
.filter(item => item.length > 0)
.map(parseLogLine)
buffer.length = 0
if (incomingLogs.length === 0) {
return
}
const shouldFollow = isNearBottom()
parsedLogs.value = [...parsedLogs.value, ...incomingLogs].slice(-MAX_LOG_LINES)
followTail.value = shouldFollow
if (shouldFollow) {
enableFollow()
return
}
pendingLogCount.value += incomingLogs.length
}
function scheduleFlush() {
if (timeoutId) {
return
}
timeoutId = window.setTimeout(() => {
flushBuffer()
}, FLUSH_DELAY)
}
function handleSSEMessage(event: MessageEvent) {
if (!event.data) {
return
}
isConnected.value = true
buffer.push(String(event.data))
scheduleFlush()
}
const { manager, isConnected } = useSSE(
`${import.meta.env.VITE_API_BASE_URL}system/logging?logfile=${encodeURIComponent(props.logfile ?? 'moviepilot.log')}`,
handleSSEMessage,
listenerId,
{
backgroundCloseDelay: 5000,
reconnectDelay: 3000,
maxReconnectAttempts: 3,
connectDelay: 300,
},
)
function pauseStream() {
if (isStreamPaused.value) {
return
}
flushBuffer()
isStreamPaused.value = true
isConnected.value = false
manager.removeMessageListener(listenerId)
}
function resumeStream() {
if (!isStreamPaused.value) {
return
}
isStreamPaused.value = false
isConnected.value = false
manager.addMessageListener(listenerId, handleSSEMessage)
}
function toggleStreamState() {
if (isStreamPaused.value) {
resumeStream()
return
}
pauseStream()
}
function handleScroll() {
if (isNearBottom()) {
followTail.value = true
pendingLogCount.value = 0
return
}
followTail.value = false
}
watch(lastVisibleLogId, (currentId, previousId) => {
if (!followTail.value || currentId === previousId) {
return
}
nextTick(() => {
scrollToBottom()
})
})
onMounted(() => {
mountTimerId = window.setTimeout(() => {
isMounted.value = true
}, 200)
})
onUnmounted(() => {
if (mountTimerId) {
clearTimeout(mountTimerId)
}
flushBuffer()
})
</script>
<template>
<VProgressLinear v-if="!isStreamPaused" class="logging-live-progress" indeterminate color="primary" height="1" />
<div
class="logging-view"
:class="{ 'is-dark-theme': isDarkTheme, 'is-transparent-theme': isTransparentTheme }"
:style="loggingViewStyle"
>
<div class="logging-toolbar px-3">
<VSelect
v-model="selectedLevel"
:items="levelOptions"
density="compact"
variant="plain"
hide-details
class="logging-level-select"
:menu-props="{ width: 'auto' }"
>
<template #selection="{ item }">
<span
:class="item.value === 'ALL' ? 'text-primary' : `text-${logColorMap[item.value] || 'secondary'}`"
class="font-weight-medium"
>
{{ item.value === 'ALL' ? t('logging.allLevels') : item.value }}
</span>
</template>
<template #item="{ props, item }">
<VListItem v-bind="props" density="compact">
<template #title>
<span
:class="item.value === 'ALL' ? 'text-primary' : `text-${logColorMap[item.value] || 'secondary'}`"
class="font-weight-medium"
>
{{ item.value === 'ALL' ? t('logging.allLevels') : item.value }}
</span>
</template>
</VListItem>
</template>
</VSelect>
<VTextField
v-model="searchQuery"
class="logging-search"
density="compact"
variant="plain"
hide-details
clearable
@click:clear="searchQuery = ''"
prepend-inner-icon="mdi-magnify"
:placeholder="t('logging.searchPlaceholder')"
/>
<VBtn
variant="text"
icon
class="logging-stream-action"
:class="{ 'is-live': !isStreamPaused }"
:title="isStreamPaused ? t('logging.resumeStream') : t('logging.pauseStream')"
@click="toggleStreamState"
>
<VIcon :icon="isStreamPaused ? 'mdi-play' : 'mdi-pause'" />
</VBtn>
</div>
<div ref="logViewportRef" class="logging-shell is-wrap" @scroll.passive="handleScroll">
<div v-if="!isMounted" class="logging-loading-overlay">
<LoadingBanner :text="t('logging.initializing') + ' ...'" />
</div>
<div v-else-if="filteredGroups.length === 0" class="logging-empty">
<VIcon :icon="parsedLogs.length === 0 ? 'mdi-console-line' : 'mdi-filter-remove-outline'" size="20" />
<span>
{{ parsedLogs.length === 0 ? t('logging.waitingForLogs') : t('common.noMatchingData') }}
</span>
</div>
<div v-else class="logging-list">
<div v-for="(group, index) in filteredGroups" :key="group.id" class="logging-record">
<div class="logging-record-time">{{ group.secondDisplay || '...' }}</div>
<div class="logging-record-panel" :class="index % 2 === 0 ? 'is-even' : 'is-odd'">
<div
class="logging-record-accent"
:class="[`level-${(group.level || 'plain').toLowerCase()}`, { 'is-burst': group.items.length > 1 }]"
/>
<div class="logging-record-lines">
<div v-for="item in group.items" :key="item.id" class="logging-record-line">
<div class="logging-record-level" :class="`level-${(item.level || 'plain').toLowerCase()}`">
{{ item.displayLevel }}
</div>
<div v-if="item.appName" class="logging-record-app">[{{ item.appName }}]</div>
<div class="logging-record-body">
<span v-if="item.timeDisplay" class="logging-record-inline-time">{{ item.timeDisplay }}</span>
{{ item.message }}
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="pendingLogCount > 0 && !followTail" class="logging-latest-action">
<VBtn
size="small"
color="primary"
variant="elevated"
prepend-icon="mdi-arrow-down"
@click="enableFollow('smooth')"
>
{{ t('logging.jumpToLatest', { count: pendingLogCount }) }}
</VBtn>
</div>
</div>
</div>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.logging-view {
--logging-shell-bg: rgba(var(--v-theme-surface), 0.96);
--logging-record-bg-even: rgba(var(--v-theme-surface-variant), 0.01);
--logging-record-bg-odd: rgba(var(--v-theme-surface-variant), 0.005);
--logging-text: rgba(var(--v-theme-on-surface), 0.88);
--logging-muted: rgba(var(--v-theme-on-surface), 0.56);
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 20rem;
}
.logging-view.is-dark-theme {
--logging-shell-bg: rgba(var(--v-theme-surface), 0.72);
--logging-record-bg-even: rgba(var(--v-theme-on-surface), 0.02);
--logging-record-bg-odd: rgba(var(--v-theme-on-surface), 0.008);
--logging-border: rgba(var(--v-theme-on-surface), 0.12);
--logging-shadow: inset 0 1px 0 rgba(255, 255, 255, 4%);
}
.logging-view.is-transparent-theme {
--logging-shell-bg: transparent;
--logging-record-bg-even: transparent;
--logging-record-bg-odd: transparent;
--logging-border: rgba(var(--v-theme-on-surface), 0.1);
--logging-shadow: none;
}
.logging-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.logging-search {
flex: 1 1 9rem;
min-inline-size: 5rem;
}
.logging-stream-action {
color: var(--logging-muted);
}
.logging-stream-action.is-live {
color: rgb(var(--v-theme-success));
}
.logging-search :deep(.v-field) {
border-radius: var(--app-field-radius);
background: transparent !important;
box-shadow: none !important;
}
.logging-search :deep(.v-field__outline),
.logging-search :deep(.v-field__overlay) {
display: none;
}
.logging-search :deep(.v-field__input) {
padding-inline-start: 0;
}
.logging-level-select {
flex: 0 0 7rem;
min-inline-size: 7rem;
}
.logging-level-select :deep(.v-field) {
border-radius: var(--app-field-radius);
background: transparent !important;
box-shadow: none !important;
}
.logging-level-select :deep(.v-field__outline),
.logging-level-select :deep(.v-field__overlay) {
display: none;
}
.logging-level-select :deep(.v-field__input) {
padding-inline: 0;
}
.logging-shell {
position: relative;
overflow: auto;
flex: 1 1 auto;
padding: 0.875rem;
border: 1px solid var(--logging-border);
background: linear-gradient(180deg, var(--logging-shell-bg), rgba(var(--v-theme-surface), 0.9));
box-shadow: var(--logging-shadow);
}
.logging-view.is-transparent-theme .logging-shell {
backdrop-filter: none;
background: transparent;
}
.logging-loading-overlay {
position: sticky;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
background: linear-gradient(180deg, rgba(var(--v-theme-surface), 0.72), rgba(var(--v-theme-surface), 0.64));
inset-block-start: 0;
margin-block-end: 0.75rem;
margin-inline: -0.875rem;
padding-block: 0.5rem 0.75rem;
padding-inline: 0.875rem;
}
.logging-view.is-dark-theme .logging-loading-overlay {
background: linear-gradient(180deg, rgba(var(--v-theme-surface), 0.62), rgba(var(--v-theme-surface), 0.52));
}
.logging-view.is-transparent-theme .logging-loading-overlay {
backdrop-filter: none;
background: transparent;
}
.logging-loading-overlay :deep(.initial-loading-container) {
min-block-size: 10rem;
}
.logging-live-progress :deep(.v-progress-linear__background) {
opacity: 0.12;
}
.logging-empty {
display: flex;
align-items: center;
justify-content: center;
block-size: 100%;
color: var(--logging-muted);
gap: 0.5rem;
min-block-size: 16rem;
}
.logging-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
min-inline-size: 100%;
}
.logging-record {
display: grid;
align-items: start;
grid-template-columns: 11rem minmax(0, 1fr);
}
.logging-record-time {
color: rgb(var(--v-theme-primary));
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.5;
padding-block: 0.625rem;
white-space: nowrap;
}
.logging-record-panel {
display: flex;
align-items: stretch;
border: 1px solid rgba(var(--v-theme-on-surface), 0.04);
border-radius: var(--app-surface-radius);
gap: 0.75rem;
min-inline-size: 0;
padding-block: 0.5rem;
padding-inline: 0.5rem;
}
.logging-record-panel.is-even {
background: var(--logging-record-bg-even);
}
.logging-record-panel.is-odd {
background: var(--logging-record-bg-odd);
}
.logging-view.is-dark-theme .logging-record-panel {
border-color: rgba(var(--v-theme-on-surface), 0.08);
}
.logging-record-accent {
flex: 0 0 auto;
align-self: flex-start;
border-radius: 999px;
background-color: rgba(var(--v-theme-on-surface), 0.24);
block-size: 0.5rem;
inline-size: 0.5rem;
margin-block-start: 0.45rem;
}
.logging-record-accent.is-burst {
align-self: stretch;
border-radius: 999px;
block-size: auto;
inline-size: 0.5rem;
margin-block-start: 0;
}
.logging-record-lines {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.125rem;
min-inline-size: 0;
}
.logging-record-line {
display: flex;
align-items: flex-start;
color: var(--logging-text);
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
font-size: 0.8125rem;
gap: 0.75rem;
line-height: 1.6;
min-inline-size: max-content;
}
.logging-shell.is-wrap .logging-record-line {
min-inline-size: 0;
}
.logging-record-level {
flex: 0 0 4rem;
font-weight: 700;
min-inline-size: 4rem;
}
.logging-record-body {
color: var(--logging-text);
min-inline-size: 0;
white-space: pre;
}
.logging-record-app,
.logging-record-inline-time {
flex: 0 0 auto;
color: var(--logging-muted);
}
.logging-record-app {
color: rgba(var(--v-theme-on-surface), 0.72);
}
.logging-shell.is-wrap .logging-record-body {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.logging-record-level.level-trace,
.logging-record-level.level-debug {
color: rgb(var(--v-theme-secondary));
}
.logging-record-level.level-info {
color: rgb(var(--v-theme-success));
}
.logging-record-level.level-warning {
color: rgb(var(--v-theme-warning));
}
.logging-record-level.level-error,
.logging-record-level.level-critical {
color: rgb(var(--v-theme-error));
}
.logging-record-level.level-plain {
color: var(--logging-muted);
}
.logging-record-accent.level-trace,
.logging-record-accent.level-debug {
background-color: rgb(var(--v-theme-secondary));
}
.logging-record-accent.level-info {
background-color: rgb(var(--v-theme-success));
}
.logging-record-accent.level-warning {
background-color: rgb(var(--v-theme-warning));
}
.logging-record-accent.level-error,
.logging-record-accent.level-critical {
background-color: rgb(var(--v-theme-error));
}
.logging-record-accent.level-plain {
background-color: rgba(var(--v-theme-on-surface), 0.24);
}
.logging-latest-action {
position: sticky;
display: flex;
justify-content: flex-end;
inset-block-end: 0.75rem;
margin-block-start: 0.75rem;
pointer-events: none;
}
.logging-latest-action :deep(.v-btn) {
box-shadow: 0 8px 24px rgba(15, 23, 42, 16%);
pointer-events: auto;
}
@media (width <= 960px) {
.logging-record {
gap: 0.375rem;
grid-template-columns: 9.5rem minmax(0, 1fr);
}
.logging-record-level {
flex-basis: 4.75rem;
min-inline-size: 4.75rem;
}
}
@media (width <= 640px) {
.logging-view {
gap: 0.5rem;
min-block-size: 0;
}
.logging-record {
gap: 0.25rem;
grid-template-columns: minmax(0, 1fr);
}
.logging-record-time {
padding-block: 0;
}
.logging-record-panel {
padding-block: 0.5rem;
padding-inline: 0.625rem;
}
.logging-record-line {
gap: 0.5rem;
}
.logging-shell {
padding: 0.625rem;
}
.logging-loading-overlay {
margin-inline: -0.625rem;
padding-inline: 0.625rem;
}
}
</style>