Files
OpenList-Desktop/src/views/LogView.vue
2025-08-27 14:13:30 +08:00

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>