mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-16 21:20:45 +08:00
1009 lines
24 KiB
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>
|