feat(logging): implement client log streaming and management
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m29s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 54s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 2m35s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m21s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m1s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m55s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m39s
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m4s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 2m29s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 54s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 2m35s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m21s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m35s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m1s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m55s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m39s
- Added log streaming functionality for clients, allowing real-time log access via SSE. - Introduced LogHandler to manage log streaming requests and responses. - Implemented LogSessionManager to handle active log sessions and listeners. - Enhanced protocol with log-related message types and structures. - Created Logger for client-side logging, supporting various log levels and file output. - Developed LogViewer component for the web interface to display and filter logs. - Updated API to support log stream creation and management. - Added support for querying logs by level and searching through log messages.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { get, post, put, del } from '../config/axios'
|
||||
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin, RuleSchemasMap } from '../types'
|
||||
import { get, post, put, del, getToken } from '../config/axios'
|
||||
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin, RuleSchemasMap, LogEntry, LogStreamOptions } from '../types'
|
||||
|
||||
// 重新导出 token 管理方法
|
||||
export { getToken, setToken, removeToken } from '../config/axios'
|
||||
@@ -104,3 +104,40 @@ export const applyServerUpdate = (downloadUrl: string, restart: boolean = true)
|
||||
post('/update/apply/server', { download_url: downloadUrl, restart })
|
||||
export const applyClientUpdate = (clientId: string, downloadUrl: string) =>
|
||||
post('/update/apply/client', { client_id: clientId, download_url: downloadUrl })
|
||||
|
||||
// 日志流
|
||||
export const createLogStream = (
|
||||
clientId: string,
|
||||
options: LogStreamOptions = {},
|
||||
onLog: (entry: LogEntry) => void,
|
||||
onError?: (error: Event) => void
|
||||
): EventSource => {
|
||||
const token = getToken()
|
||||
const params = new URLSearchParams()
|
||||
if (token) params.append('token', token)
|
||||
if (options.lines !== undefined) params.append('lines', String(options.lines))
|
||||
if (options.follow !== undefined) params.append('follow', String(options.follow))
|
||||
if (options.level) params.append('level', options.level)
|
||||
|
||||
const url = `/api/client/${clientId}/logs?${params.toString()}`
|
||||
const eventSource = new EventSource(url)
|
||||
|
||||
eventSource.addEventListener('log', (event) => {
|
||||
try {
|
||||
const entry = JSON.parse((event as MessageEvent).data) as LogEntry
|
||||
onLog(entry)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse log entry', e)
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.addEventListener('heartbeat', () => {
|
||||
// Keep-alive, no action needed
|
||||
})
|
||||
|
||||
if (onError) {
|
||||
eventSource.onerror = onError
|
||||
}
|
||||
|
||||
return eventSource
|
||||
}
|
||||
|
||||
232
web/src/components/LogViewer.vue
Normal file
232
web/src/components/LogViewer.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { NCard, NSpace, NButton, NSelect, NSwitch, NInput, NIcon, NEmpty, NSpin } from 'naive-ui'
|
||||
import { PlayOutline, StopOutline, TrashOutline, DownloadOutline } from '@vicons/ionicons5'
|
||||
import { createLogStream } from '../api'
|
||||
import type { LogEntry } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
clientId: string
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const logs = ref<LogEntry[]>([])
|
||||
const isStreaming = ref(false)
|
||||
const autoScroll = ref(true)
|
||||
const levelFilter = ref<string>('')
|
||||
const searchText = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
const logContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
const levelOptions = [
|
||||
{ label: '所有级别', value: '' },
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Warning', value: 'warn' },
|
||||
{ label: 'Error', value: 'error' },
|
||||
{ label: 'Debug', value: 'debug' }
|
||||
]
|
||||
|
||||
const startStream = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
isStreaming.value = true
|
||||
|
||||
eventSource = createLogStream(
|
||||
props.clientId,
|
||||
{ lines: 500, follow: true, level: levelFilter.value },
|
||||
(entry) => {
|
||||
logs.value.push(entry)
|
||||
// 限制内存中的日志数量
|
||||
if (logs.value.length > 2000) {
|
||||
logs.value = logs.value.slice(-1000)
|
||||
}
|
||||
if (autoScroll.value) {
|
||||
nextTick(() => scrollToBottom())
|
||||
}
|
||||
loading.value = false
|
||||
},
|
||||
() => {
|
||||
isStreaming.value = false
|
||||
loading.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const stopStream = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
isStreaming.value = false
|
||||
}
|
||||
|
||||
const clearLogs = () => {
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (logContainer.value) {
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const downloadLogs = () => {
|
||||
const content = logs.value.map(l =>
|
||||
`${new Date(l.ts).toISOString()} [${l.level.toUpperCase()}] [${l.src}] ${l.msg}`
|
||||
).join('\n')
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${props.clientId}-logs-${new Date().toISOString().slice(0, 10)}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (!searchText.value) return logs.value
|
||||
const search = searchText.value.toLowerCase()
|
||||
return logs.value.filter(l => l.msg.toLowerCase().includes(search))
|
||||
})
|
||||
|
||||
const getLevelColor = (level: string): string => {
|
||||
switch (level) {
|
||||
case 'error': return '#e88080'
|
||||
case 'warn': return '#e8b880'
|
||||
case 'info': return '#80b8e8'
|
||||
case 'debug': return '#808080'
|
||||
default: return '#ffffff'
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (ts: number): string => {
|
||||
return new Date(ts).toLocaleTimeString('en-US', { hour12: false })
|
||||
}
|
||||
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible) {
|
||||
startStream()
|
||||
} else {
|
||||
stopStream()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.visible) {
|
||||
startStream()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopStream()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card title="客户端日志" :closable="true" @close="emit('close')">
|
||||
<template #header-extra>
|
||||
<n-space :size="8">
|
||||
<n-select
|
||||
v-model:value="levelFilter"
|
||||
:options="levelOptions"
|
||||
size="small"
|
||||
style="width: 110px;"
|
||||
@update:value="() => { stopStream(); logs = []; startStream(); }"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="searchText"
|
||||
placeholder="搜索..."
|
||||
size="small"
|
||||
style="width: 120px;"
|
||||
clearable
|
||||
/>
|
||||
<n-switch v-model:value="autoScroll" size="small">
|
||||
<template #checked>自动滚动</template>
|
||||
<template #unchecked>手动</template>
|
||||
</n-switch>
|
||||
<n-button size="small" quaternary @click="clearLogs">
|
||||
<template #icon><n-icon><TrashOutline /></n-icon></template>
|
||||
</n-button>
|
||||
<n-button size="small" quaternary @click="downloadLogs">
|
||||
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
:type="isStreaming ? 'error' : 'success'"
|
||||
@click="isStreaming ? stopStream() : startStream()"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon><StopOutline v-if="isStreaming" /><PlayOutline v-else /></n-icon>
|
||||
</template>
|
||||
{{ isStreaming ? '停止' : '开始' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-spin :show="loading && logs.length === 0">
|
||||
<div
|
||||
ref="logContainer"
|
||||
class="log-container"
|
||||
>
|
||||
<n-empty v-if="filteredLogs.length === 0" description="暂无日志" />
|
||||
<div
|
||||
v-for="(log, i) in filteredLogs"
|
||||
:key="i"
|
||||
class="log-line"
|
||||
>
|
||||
<span class="log-time">{{ formatTime(log.ts) }}</span>
|
||||
<span class="log-level" :style="{ color: getLevelColor(log.level) }">[{{ log.level.toUpperCase() }}]</span>
|
||||
<span class="log-src">[{{ log.src }}]</span>
|
||||
<span class="log-msg">{{ log.msg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-container {
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
padding: 8px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #808080;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.log-src {
|
||||
color: #a0a0a0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.log-msg {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
</style>
|
||||
@@ -130,3 +130,18 @@ export interface JSPlugin {
|
||||
|
||||
// 规则配置模式集合
|
||||
export type RuleSchemasMap = Record<string, RuleSchema>
|
||||
|
||||
// 日志条目
|
||||
export interface LogEntry {
|
||||
ts: number // Unix 时间戳 (毫秒)
|
||||
level: string // 日志级别: debug, info, warn, error
|
||||
msg: string // 日志消息
|
||||
src: string // 来源: client, plugin:<name>
|
||||
}
|
||||
|
||||
// 日志流选项
|
||||
export interface LogStreamOptions {
|
||||
lines?: number // 初始日志行数
|
||||
follow?: boolean // 是否持续推送
|
||||
level?: string // 日志级别过滤
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import {
|
||||
ArrowBackOutline, CreateOutline, TrashOutline,
|
||||
PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline,
|
||||
SettingsOutline, StorefrontOutline, RefreshOutline, StopOutline, PlayOutline
|
||||
SettingsOutline, StorefrontOutline, RefreshOutline, StopOutline, PlayOutline, DocumentTextOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import {
|
||||
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getStorePlugins, installStorePlugin, getRuleSchemas, startClientPlugin, restartClientPlugin, stopClientPlugin, deleteClientPlugin
|
||||
} from '../api'
|
||||
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
|
||||
import LogViewer from '../components/LogViewer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -88,6 +89,9 @@ const storeLoading = ref(false)
|
||||
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
|
||||
const storeInstalling = ref(false)
|
||||
|
||||
// 日志查看相关
|
||||
const showLogViewer = ref(false)
|
||||
|
||||
// 商店插件相关函数
|
||||
const openStoreModal = async () => {
|
||||
showStoreModal.value = true
|
||||
@@ -418,6 +422,10 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
<template #icon><n-icon><PushOutline /></n-icon></template>
|
||||
推送配置
|
||||
</n-button>
|
||||
<n-button @click="showLogViewer = true">
|
||||
<template #icon><n-icon><DocumentTextOutline /></n-icon></template>
|
||||
查看日志
|
||||
</n-button>
|
||||
<n-button @click="openStoreModal">
|
||||
<template #icon><n-icon><StorefrontOutline /></n-icon></template>
|
||||
从商店安装
|
||||
@@ -731,5 +739,10 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 日志查看模态框 -->
|
||||
<n-modal v-model:show="showLogViewer" preset="card" style="width: 900px; max-width: 95vw;">
|
||||
<LogViewer :client-id="clientId" :visible="showLogViewer" @close="showLogViewer = false" />
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user