mirror of
https://github.com/OpenListTeam/OpenList-Desktop.git
synced 2025-11-25 03:14:56 +08:00
683 lines
21 KiB
Vue
683 lines
21 KiB
Vue
<script setup lang="ts">
|
|
import * as chrono from 'chrono-node'
|
|
import {
|
|
AlertCircle,
|
|
AlertTriangle,
|
|
ArrowDown,
|
|
ArrowUp,
|
|
Copy,
|
|
Download,
|
|
Filter,
|
|
FolderOpen,
|
|
Info,
|
|
Maximize2,
|
|
Minimize2,
|
|
Pause,
|
|
Play,
|
|
RotateCcw,
|
|
Search,
|
|
Settings,
|
|
Trash2
|
|
} from 'lucide-vue-next'
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
|
|
import { useTranslation } from '../composables/useI18n'
|
|
import { useAppStore } from '../stores/app'
|
|
|
|
type filterSourceType = 'openlist' | 'rclone' | 'app' | 'service' | 'all'
|
|
|
|
const appStore = useAppStore()
|
|
const { t } = useTranslation()
|
|
const logContainer = ref<HTMLElement>()
|
|
const searchInputRef = ref<HTMLInputElement>()
|
|
const autoScroll = ref(true)
|
|
const isPaused = ref(false)
|
|
const filterLevel = ref<string>('all')
|
|
const filterSource = ref<string>(localStorage.getItem('logFilterSource') || 'openlist')
|
|
const searchQuery = ref('')
|
|
const selectedEntries = ref<Set<number>>(new Set())
|
|
const showFilters = ref(true)
|
|
const showSettings = ref(false)
|
|
const fontSize = ref(13)
|
|
const maxLines = ref(1000)
|
|
const isCompactMode = ref(false)
|
|
const isFullscreen = ref(false)
|
|
const stripAnsiColors = ref(true)
|
|
const showNotification = ref(false)
|
|
const notificationMessage = ref('')
|
|
const notificationType = ref<'success' | 'info' | 'warning' | 'error'>('success')
|
|
const showConfirmDialog = ref(false)
|
|
const confirmDialogConfig = ref({
|
|
title: '',
|
|
message: '',
|
|
onConfirm: () => {},
|
|
onCancel: () => {}
|
|
})
|
|
|
|
watch(filterSource, async newValue => {
|
|
localStorage.setItem('logFilterSource', newValue)
|
|
await appStore.loadLogs((newValue !== 'gin' ? newValue : 'openlist') as filterSourceType)
|
|
await scrollToBottom()
|
|
})
|
|
|
|
let logRefreshInterval: NodeJS.Timeout | null = null
|
|
|
|
const showNotificationMessage = (message: string, type: 'success' | 'info' | 'warning' | 'error' = 'success') => {
|
|
notificationMessage.value = message
|
|
notificationType.value = type
|
|
showNotification.value = true
|
|
|
|
setTimeout(() => {
|
|
showNotification.value = false
|
|
}, 3000)
|
|
}
|
|
|
|
const openLogsDirectory = async () => {
|
|
try {
|
|
await appStore.openLogsDirectory()
|
|
showNotificationMessage(t('logs.notifications.openDirectorySuccess'), 'success')
|
|
} catch (error) {
|
|
console.error('Failed to open logs directory:', error)
|
|
showNotificationMessage(t('logs.notifications.openDirectoryFailed'), 'error')
|
|
}
|
|
}
|
|
|
|
const stripAnsiCodes = (text: string): string => {
|
|
return text.replace(/\u001b\[[0-9;]*[mGKHF]/g, '')
|
|
}
|
|
|
|
const parseLogEntry = (logText: string) => {
|
|
const cleanText = stripAnsiColors.value ? stripAnsiCodes(logText).trim() : logText.trim()
|
|
const originalText = logText.trim()
|
|
|
|
let level = 'info'
|
|
let timestamp = ''
|
|
let source = 'openlist'
|
|
let message = cleanText
|
|
|
|
const levelMatch = cleanText.match(/^(WARN|ERROR|INFO|DEBUG|info|debug|warn|error)/i)
|
|
if (levelMatch) {
|
|
level = levelMatch[1].toLowerCase()
|
|
}
|
|
|
|
const timestampMatch = cleanText.match(/(\d{4}[-/]\d{2}[-/]\d{2}[T\s-]*\d{2}:\d{2}:\d{2})/)
|
|
if (timestampMatch) {
|
|
timestamp = timestampMatch[1]
|
|
} else {
|
|
timestamp = chrono.parseDate(cleanText)?.toISOString().replace('T', ' ').substring(0, 19) || ''
|
|
}
|
|
|
|
if (cleanText.includes('[GIN]')) {
|
|
source = 'gin'
|
|
level = 'info'
|
|
const ginMatch = cleanText.match(/\[GIN\]\s*(.+)/)
|
|
if (ginMatch) {
|
|
message = ginMatch[1]
|
|
const statusMatch = message.match(/\|\s*(\d{3})\s*\|/)
|
|
if (statusMatch) {
|
|
const statusCode = parseInt(statusMatch[1])
|
|
if (statusCode >= 400 && statusCode < 500) {
|
|
level = 'warn'
|
|
} else if (statusCode >= 500) {
|
|
level = 'error'
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
source = filterSource.value
|
|
}
|
|
|
|
message = message
|
|
.replace(/^(WARN|ERROR|INFO|DEBUG)\s*/i, '')
|
|
.replace(/^\[\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\]\s*/, '')
|
|
.replace(/^\d{4}\/\d{2}\/\d{2}\s*-\s*\d{2}:\d{2}:\d{2}\s*\|\s*/, '')
|
|
.trim()
|
|
|
|
const timeMatch = timestamp.match(/(\d{2}:\d{2}:\d{2})/)
|
|
const displayTime = timeMatch ? timeMatch[1] : timestamp
|
|
|
|
return {
|
|
timestamp: displayTime,
|
|
level,
|
|
source,
|
|
message: message || cleanText,
|
|
original: cleanText,
|
|
rawMessage: stripAnsiColors.value ? message : originalText,
|
|
fullTimestamp: timestamp
|
|
}
|
|
}
|
|
|
|
const filteredLogs = computed(() => {
|
|
let logs = appStore.logs
|
|
.slice(-maxLines.value)
|
|
.filter((log: string | string[]) => !log.includes('/ping'))
|
|
.map(parseLogEntry)
|
|
|
|
if (filterLevel.value !== 'all') {
|
|
logs = logs.filter((log: any) => log.level === filterLevel.value)
|
|
}
|
|
|
|
if (filterSource.value !== 'all') {
|
|
logs = logs.filter((log: any) => log.source === filterSource.value)
|
|
}
|
|
|
|
if (searchQuery.value.trim()) {
|
|
const query = searchQuery.value.toLowerCase()
|
|
logs = logs.filter(
|
|
(log: any) =>
|
|
log.message.toLowerCase().includes(query) ||
|
|
log.source.toLowerCase().includes(query) ||
|
|
log.level.toLowerCase().includes(query)
|
|
)
|
|
}
|
|
|
|
return logs
|
|
})
|
|
|
|
const logLevelClass = (level: string) => {
|
|
switch (level) {
|
|
case 'error':
|
|
return 'log-error'
|
|
case 'warn':
|
|
return 'log-warning'
|
|
case 'info':
|
|
return 'log-info'
|
|
case 'debug':
|
|
return 'log-debug'
|
|
default:
|
|
return 'log-info'
|
|
}
|
|
}
|
|
|
|
const scrollToBottom = async () => {
|
|
if (autoScroll.value && !isPaused.value && logContainer.value) {
|
|
await nextTick()
|
|
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
|
}
|
|
}
|
|
|
|
const scrollToTop = () => {
|
|
if (logContainer.value) {
|
|
logContainer.value.scrollTop = 0
|
|
}
|
|
}
|
|
|
|
const clearLogs = async () => {
|
|
confirmDialogConfig.value = {
|
|
title: t('logs.messages.confirmTitle') || t('common.confirm'),
|
|
message: t('logs.messages.confirmClear'),
|
|
onConfirm: async () => {
|
|
showConfirmDialog.value = false
|
|
try {
|
|
await appStore.clearLogs(
|
|
(filterSource.value !== 'all' && filterSource.value !== 'gin'
|
|
? filterSource.value
|
|
: 'openlist') as filterSourceType
|
|
)
|
|
selectedEntries.value.clear()
|
|
showNotificationMessage(t('logs.notifications.clearSuccess'), 'success')
|
|
} catch (error) {
|
|
console.error('Failed to clear logs:', error)
|
|
showNotificationMessage(t('logs.notifications.clearFailed'), 'error')
|
|
}
|
|
},
|
|
onCancel: () => {
|
|
showConfirmDialog.value = false
|
|
}
|
|
}
|
|
|
|
showConfirmDialog.value = true
|
|
}
|
|
|
|
const copyLogsToClipboard = async () => {
|
|
let logsToExport = filteredLogs.value
|
|
|
|
if (selectedEntries.value.size > 0) {
|
|
logsToExport = filteredLogs.value.filter((_, index) => selectedEntries.value.has(index))
|
|
}
|
|
|
|
const logsText = logsToExport
|
|
.map((log: any) => `[${log.timestamp || 'N/A'}] [${log.level.toUpperCase()}] [${log.source}] ${log.message}`)
|
|
.join('\n')
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(logsText)
|
|
const count = selectedEntries.value.size > 0 ? selectedEntries.value.size : filteredLogs.value.length
|
|
showNotificationMessage(t('logs.notifications.copySuccess', { count }), 'success')
|
|
} catch (error) {
|
|
console.error('Failed to copy logs:', error)
|
|
showNotificationMessage(t('logs.notifications.copyFailed'), 'error')
|
|
}
|
|
}
|
|
|
|
const exportLogs = () => {
|
|
let logsToExport = filteredLogs.value
|
|
|
|
if (selectedEntries.value.size > 0) {
|
|
logsToExport = filteredLogs.value.filter((_, index) => selectedEntries.value.has(index))
|
|
}
|
|
|
|
const logsText = logsToExport
|
|
.map((log: any) => `[${log.timestamp || 'N/A'}] [${log.level.toUpperCase()}] [${log.source}] ${log.message}`)
|
|
.join('\n')
|
|
|
|
const blob = new Blob([logsText], { type: 'text/plain' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `openlist-logs-${new Date().toISOString().split('T')[0]}.txt`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
|
|
const count = selectedEntries.value.size > 0 ? selectedEntries.value.size : filteredLogs.value.length
|
|
showNotificationMessage(t('logs.notifications.exportSuccess', { count }), 'success')
|
|
}
|
|
|
|
const toggleSelectEntry = (index: number) => {
|
|
if (selectedEntries.value.has(index)) {
|
|
selectedEntries.value.delete(index)
|
|
} else {
|
|
selectedEntries.value.add(index)
|
|
}
|
|
}
|
|
|
|
const selectAllVisible = () => {
|
|
filteredLogs.value.forEach((_: any, index: number) => {
|
|
selectedEntries.value.add(index)
|
|
})
|
|
}
|
|
|
|
const clearSelection = () => {
|
|
selectedEntries.value.clear()
|
|
}
|
|
|
|
const togglePause = () => {
|
|
isPaused.value = !isPaused.value
|
|
}
|
|
|
|
const refreshLogs = async () => {
|
|
await appStore.loadLogs(
|
|
(filterSource.value !== 'all' && filterSource.value !== 'gin' ? filterSource.value : 'openlist') as filterSourceType
|
|
)
|
|
await scrollToBottom()
|
|
if (isPaused.value) {
|
|
isPaused.value = false
|
|
}
|
|
}
|
|
|
|
const toggleFullscreen = () => {
|
|
isFullscreen.value = !isFullscreen.value
|
|
if (isFullscreen.value) {
|
|
document.documentElement.requestFullscreen?.()
|
|
} else {
|
|
document.exitFullscreen?.()
|
|
}
|
|
}
|
|
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
const ctrl = event.ctrlKey
|
|
const key = event.key.toLowerCase()
|
|
|
|
if (ctrl) {
|
|
switch (key) {
|
|
case 'f':
|
|
event.preventDefault()
|
|
searchInputRef.value?.focus()
|
|
break
|
|
case 'a':
|
|
event.preventDefault()
|
|
selectAllVisible()
|
|
break
|
|
case 'c':
|
|
if (selectedEntries.value.size > 0) {
|
|
event.preventDefault()
|
|
copyLogsToClipboard()
|
|
}
|
|
break
|
|
case 'r':
|
|
event.preventDefault()
|
|
refreshLogs()
|
|
break
|
|
case 'delete':
|
|
event.preventDefault()
|
|
clearLogs()
|
|
break
|
|
}
|
|
} else {
|
|
switch (key) {
|
|
case 'escape':
|
|
clearSelection()
|
|
searchQuery.value = ''
|
|
break
|
|
case 'home':
|
|
event.preventDefault()
|
|
scrollToTop()
|
|
break
|
|
case 'end':
|
|
event.preventDefault()
|
|
scrollToBottom()
|
|
break
|
|
case 'f11':
|
|
event.preventDefault()
|
|
toggleFullscreen()
|
|
break
|
|
case ' ':
|
|
if (event.target === document.body) {
|
|
event.preventDefault()
|
|
togglePause()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
appStore.loadLogs((filterSource.value !== 'gin' ? filterSource.value : 'openlist') as filterSourceType).then(() => {
|
|
scrollToBottom()
|
|
})
|
|
|
|
document.addEventListener('keydown', handleKeydown)
|
|
|
|
logRefreshInterval = setInterval(async () => {
|
|
if (!isPaused.value) {
|
|
const oldLength = appStore.logs.length
|
|
await appStore.loadLogs((filterSource.value !== 'gin' ? filterSource.value : 'openlist') as filterSourceType)
|
|
|
|
if (appStore.logs.length > oldLength) {
|
|
await scrollToBottom()
|
|
}
|
|
}
|
|
}, 30 * 1000)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (logRefreshInterval) {
|
|
clearInterval(logRefreshInterval)
|
|
}
|
|
document.removeEventListener('keydown', handleKeydown)
|
|
})
|
|
|
|
const unwatchLogs = appStore.$subscribe(mutation => {
|
|
if (mutation.storeId === 'app') {
|
|
const events = Array.isArray(mutation.events) ? mutation.events : [mutation.events]
|
|
if (events.some((event: any) => event.key === 'logs')) {
|
|
scrollToBottom()
|
|
}
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
unwatchLogs()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="log-view" :class="{ fullscreen: isFullscreen, compact: isCompactMode }">
|
|
<div class="log-toolbar">
|
|
<div class="toolbar-section left">
|
|
<button
|
|
class="toolbar-btn"
|
|
:class="{ active: isPaused }"
|
|
:title="isPaused ? t('logs.toolbar.resume') : t('logs.toolbar.pause')"
|
|
@click="togglePause"
|
|
>
|
|
<Pause v-if="!isPaused" :size="16" />
|
|
<Play v-else :size="16" />
|
|
</button>
|
|
|
|
<button class="toolbar-btn" :title="t('logs.toolbar.refresh')" @click="refreshLogs">
|
|
<RotateCcw :size="16" />
|
|
</button>
|
|
|
|
<div class="toolbar-separator"></div>
|
|
|
|
<button
|
|
class="toolbar-btn"
|
|
:class="{ active: showFilters }"
|
|
:title="t('logs.toolbar.showFilters')"
|
|
@click="showFilters = !showFilters"
|
|
>
|
|
<Filter :size="16" />
|
|
</button>
|
|
|
|
<button
|
|
class="toolbar-btn"
|
|
:class="{ active: showSettings }"
|
|
:title="t('logs.toolbar.settings')"
|
|
@click="showSettings = !showSettings"
|
|
>
|
|
<Settings :size="16" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-section center">
|
|
<div class="search-container">
|
|
<Search :size="14" class="search-icon" />
|
|
<input
|
|
ref="searchInputRef"
|
|
v-model="searchQuery"
|
|
type="text"
|
|
:placeholder="t('logs.search.placeholder')"
|
|
class="search-input"
|
|
@keydown.escape="searchQuery = ''"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toolbar-section right">
|
|
<div class="log-stats">
|
|
<span class="stat">{{
|
|
t('logs.stats.logsCount', { filtered: filteredLogs.length, total: appStore.logs.length })
|
|
}}</span>
|
|
<span v-if="selectedEntries.size > 0" class="stat selected">
|
|
{{ t('logs.stats.selected', { count: selectedEntries.size }) }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="toolbar-separator"></div>
|
|
|
|
<button
|
|
class="toolbar-btn"
|
|
:title="t('logs.toolbar.copyToClipboard')"
|
|
:disabled="filteredLogs.length === 0"
|
|
@click="copyLogsToClipboard"
|
|
>
|
|
<Copy :size="16" />
|
|
</button>
|
|
|
|
<button
|
|
class="toolbar-btn"
|
|
:title="t('logs.toolbar.exportLogs')"
|
|
:disabled="filteredLogs.length === 0"
|
|
@click="exportLogs"
|
|
>
|
|
<Download :size="16" />
|
|
</button>
|
|
|
|
<button
|
|
class="toolbar-btn danger"
|
|
:disabled="filteredLogs.length === 0 || filterSource === 'gin' || filterSource === 'all'"
|
|
:title="t('logs.toolbar.clearLogs')"
|
|
@click="clearLogs"
|
|
>
|
|
<Trash2 :size="16" />
|
|
</button>
|
|
|
|
<button class="toolbar-btn" :title="t('logs.toolbar.openLogsDirectory')" @click="openLogsDirectory">
|
|
<FolderOpen :size="16" />
|
|
</button>
|
|
|
|
<div class="toolbar-separator"></div>
|
|
|
|
<button class="toolbar-btn" :title="t('logs.toolbar.toggleFullscreen')" @click="toggleFullscreen">
|
|
<Maximize2 v-if="!isFullscreen" :size="16" />
|
|
<Minimize2 v-else :size="16" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showFilters" class="filters-panel">
|
|
<div class="filter-group">
|
|
<label>{{ t('logs.filters.labels.level') }}</label>
|
|
<select v-model="filterLevel" class="filter-select">
|
|
<option value="all">{{ t('logs.filters.levels.all') }}</option>
|
|
<option value="debug">{{ t('logs.filters.levels.debug') }}</option>
|
|
<option value="info">{{ t('logs.filters.levels.info') }}</option>
|
|
<option value="warn">{{ t('logs.filters.levels.warn') }}</option>
|
|
<option value="error">{{ t('logs.filters.levels.error') }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>{{ t('logs.filters.labels.source') }}</label>
|
|
<select v-model="filterSource" class="filter-select">
|
|
<option value="all">{{ t('logs.filters.sources.all') }}</option>
|
|
<option value="openlist">{{ t('logs.filters.sources.openlist') }}</option>
|
|
<option value="gin">GIN Server</option>
|
|
<option value="rclone">{{ t('logs.filters.sources.rclone') }}</option>
|
|
<option value="service">{{ t('logs.filters.sources.service') }}</option>
|
|
<option value="app">{{ t('logs.filters.app') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-actions">
|
|
<button class="filter-btn" :disabled="filteredLogs.length === 0" @click="selectAllVisible">
|
|
{{ t('logs.filters.actions.selectAll') }}
|
|
</button>
|
|
|
|
<button class="filter-btn" :disabled="selectedEntries.size === 0" @click="clearSelection">
|
|
{{ t('logs.filters.actions.clearSelection') }}
|
|
</button>
|
|
|
|
<label class="checkbox-label">
|
|
<input v-model="autoScroll" type="checkbox" class="checkbox" />
|
|
{{ t('logs.filters.actions.autoScroll') }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showSettings" class="settings-panel">
|
|
<div class="setting-group">
|
|
<label>{{ t('logs.settings.fontSize') }}</label>
|
|
<input v-model="fontSize" type="range" min="10" max="20" class="range-input" />
|
|
<span class="setting-value">{{ fontSize }}px</span>
|
|
</div>
|
|
|
|
<div class="setting-group">
|
|
<label>{{ t('logs.settings.maxLines') }}</label>
|
|
<input v-model="maxLines" type="number" min="100" max="10000" step="100" class="number-input" />
|
|
</div>
|
|
<div class="setting-group">
|
|
<label class="checkbox-label">
|
|
<input v-model="isCompactMode" type="checkbox" class="checkbox" />
|
|
{{ t('logs.settings.compactMode') }}
|
|
</label>
|
|
|
|
<label class="checkbox-label">
|
|
<input v-model="stripAnsiColors" type="checkbox" class="checkbox" />
|
|
{{ t('logs.settings.stripAnsiColors') }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="log-container">
|
|
<div class="log-header">
|
|
<div class="log-col timestamp">{{ t('logs.headers.timestamp') }}</div>
|
|
<div class="log-col level">{{ t('logs.headers.level') }}</div>
|
|
<div class="log-col source">{{ t('logs.headers.source') }}</div>
|
|
<div class="log-col message">{{ t('logs.headers.message') }}</div>
|
|
<div class="log-col actions">
|
|
<button class="scroll-btn" :title="t('logs.toolbar.scrollToTop')" @click="scrollToTop">
|
|
<ArrowUp :size="14" />
|
|
</button>
|
|
<button class="scroll-btn" :title="t('logs.toolbar.scrollToBottom')" @click="scrollToBottom">
|
|
<ArrowDown :size="14" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref="logContainer" class="log-content" :style="{ fontSize: fontSize + 'px' }">
|
|
<div v-if="filteredLogs.length === 0" class="empty-state">
|
|
<div class="empty-icon">📄</div>
|
|
<h3>{{ t('logs.viewer.noLogsFound') }}</h3>
|
|
<p v-if="searchQuery">{{ t('logs.viewer.noLogsMatch') }}</p>
|
|
<p v-else>{{ t('logs.viewer.logsWillAppear') }}</p>
|
|
</div>
|
|
<div
|
|
v-for="(log, index) in filteredLogs"
|
|
:key="index"
|
|
class="log-entry"
|
|
:class="[
|
|
logLevelClass(log.level),
|
|
{
|
|
selected: selectedEntries.has(index),
|
|
compact: isCompactMode
|
|
}
|
|
]"
|
|
@click="toggleSelectEntry(index)"
|
|
>
|
|
<div class="log-col timestamp">
|
|
{{ log.timestamp || '--:--:--' }}
|
|
</div>
|
|
<div class="log-col level">
|
|
<span class="level-badge" :class="log.level">
|
|
{{ log.level.toUpperCase() }}
|
|
</span>
|
|
</div>
|
|
<div class="log-col source" :data-source="log.source">
|
|
{{ log.source }}
|
|
</div>
|
|
<div class="log-col message">
|
|
{{ log.message }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status-bar">
|
|
<div class="status-left">
|
|
<span class="status-item">
|
|
{{ t('logs.status.autoScroll') }} {{ autoScroll ? t('logs.status.on') : t('logs.status.off') }}
|
|
</span>
|
|
<span class="status-item">
|
|
{{ t('logs.status.updates') }} {{ isPaused ? t('logs.status.paused') : t('logs.status.live') }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="status-right">
|
|
<span class="status-item">
|
|
{{ t('logs.status.showing', { filtered: filteredLogs.length, total: appStore.logs.length }) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<Transition name="notification">
|
|
<div v-if="showNotification" class="notification-toast" :class="[`notification-${notificationType}`]">
|
|
<div class="notification-content">
|
|
<div class="notification-icon">
|
|
<Copy v-if="notificationType === 'success'" :size="20" />
|
|
<AlertCircle v-else-if="notificationType === 'error'" :size="20" />
|
|
<Info v-else-if="notificationType === 'info'" :size="20" />
|
|
<AlertTriangle v-else-if="notificationType === 'warning'" :size="20" />
|
|
</div>
|
|
<span class="notification-message">{{ notificationMessage }}</span>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<ConfirmDialog
|
|
:is-open="showConfirmDialog"
|
|
:title="confirmDialogConfig.title"
|
|
:message="confirmDialogConfig.message"
|
|
:confirm-text="t('common.confirm')"
|
|
:cancel-text="t('common.cancel')"
|
|
variant="danger"
|
|
@confirm="confirmDialogConfig.onConfirm"
|
|
@cancel="confirmDialogConfig.onCancel"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped src="./css/LogView.css"></style>
|