Files
Havline/web/src/views/LogsView.vue
T
mrlee 1b30be45a0 fix(web): FRP 日志页签解析、级别筛选与呈现对齐系统日志
解析:容忍行首 [组件] 前缀(真实行为 [web] …),来源取 [文件:行号]、缺失时回落组件名,级别统一大写

筛选:级别选项值 INFO/WARN/ERROR 与 frpc 单字母级别对齐(原先与中文标签比较永不相等,选中即过滤为空),并补筛选变化重置页码

呈现:级别/来源改小号标签、消息单行省略带 tooltip、时间走 logTimeCell,与「系统日志」页签一致;空态按筛选结果判定,区分「暂无日志」与「没有匹配的日志」
2026-09-23 18:08:31 +08:00

770 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<PageHeader
title="日志中心"
description="系统日志记录 Fonu 全部运行输出;访问日志与 Nginx 错误日志来自反向代理"
/>
<LoadError v-if="pageError" :message="pageError" @retry="loadAll" />
<FonuCard v-else flush class="logs-card">
<n-tabs v-model:value="tab" type="line" animated class="logs-tabs">
<n-tab-pane name="system" tab="系统日志">
<LogToolbar
v-model:keyword="systemKeyword"
v-model:level="systemLevel"
:show-level="true"
@refresh="loadSystemLogs"
/>
<p class="tab-hint">Fonu 全部运行日志(API、DDNS、证书、通知、Nginx 重载等),不含 Nginx 访问与错误文件。</p>
<n-data-table
v-if="filteredSystemLogs.length > 0"
class="log-table"
:columns="systemColumns"
:data="pagedSystemLogs"
:loading="loadingSystem"
:bordered="false"
:row-class-name="systemRowClassName"
/>
<LogPagination
v-if="filteredSystemLogs.length > 0"
v-model:page="systemPage"
:item-count="filteredSystemLogs.length"
/>
<EmptyState
v-if="!loadingSystem && systemLogs.length === 0"
title="暂无系统日志"
description="应用启动、DDNS 更新、证书操作或 API 请求会记录在这里。"
/>
</n-tab-pane>
<n-tab-pane name="frp" tab="FRP 日志">
<LogToolbar
v-model:keyword="frpKeyword"
v-model:level="frpLevel"
:show-level="true"
@refresh="loadFrpLogs"
/>
<p class="tab-hint">frpc 运行日志:解析为 时间 / 级别 / 来源 / 消息,用于排查隧道登录、注册与连接错误。</p>
<n-data-table
v-if="filteredFrpLogs.length > 0"
class="log-table"
:columns="frpColumns"
:data="pagedFrpLogs"
:loading="loadingFrp"
:bordered="false"
:row-class-name="frpRowClassName"
/>
<LogPagination
v-if="filteredFrpLogs.length > 0"
v-model:page="frpPage"
:item-count="filteredFrpLogs.length"
/>
<EmptyState
v-if="!loadingFrp && filteredFrpLogs.length === 0"
:title="frpLogs.length === 0 ? '暂无 FRP 日志' : '没有匹配的日志'"
:description="frpLogs.length === 0 ? '启动 frpc 后,登录、隧道注册与连接错误会记录在这里。' : '换个关键词或级别再试。'"
/>
</n-tab-pane>
<n-tab-pane name="access" tab="访问日志">
<LogToolbar
v-model:keyword="accessKeyword"
v-model:status="accessStatus"
:show-domain="true"
:show-status="true"
:auto-refresh="autoRefresh"
@refresh="loadAccess"
@toggle-auto="autoRefresh = !autoRefresh"
/>
<n-data-table
v-if="filteredAccessLogs.length > 0"
class="log-table"
:columns="accessColumns"
:data="pagedAccessLogs"
:loading="loadingAccess"
:bordered="false"
:scroll-x="920"
:row-class-name="accessRowClassName"
/>
<LogPagination
v-if="filteredAccessLogs.length > 0"
v-model:page="accessPage"
:item-count="filteredAccessLogs.length"
/>
<EmptyState
v-if="!loadingAccess && accessLogs.length === 0"
title="暂无访问日志"
description="此处记录经 Nginx 反向代理的访问(非 Fonu 管理界面本身)。请通过代理域名访问后刷新。"
/>
</n-tab-pane>
<n-tab-pane name="nginx" tab="Nginx 日志">
<p class="tab-hint">Nginx 错误日志(error.log),记录 SSL 握手失败、上游连接异常、配置冲突等。</p>
<LogToolbar v-model:keyword="errorKeyword" @refresh="loadErrorLogs" />
<n-data-table
v-if="filteredErrorLogs.length > 0"
class="log-table"
:columns="errorColumns"
:data="pagedErrorLogs"
:loading="loadingError"
:bordered="false"
:row-class-name="errorRowClassName"
/>
<LogPagination
v-if="filteredErrorLogs.length > 0"
v-model:page="errorPage"
:item-count="filteredErrorLogs.length"
/>
<EmptyState
v-if="!loadingError && errorLogs.length === 0"
title="暂无 Nginx 错误日志"
description="Nginx 出现 SSL、上游或配置相关错误时会记录在这里。"
/>
</n-tab-pane>
</n-tabs>
</FonuCard>
</template>
<script setup lang="ts">
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
NButton,
NDataTable,
NInput,
NPagination,
NSelect,
NTabPane,
NTabs,
NTag,
useMessage,
type DataTableColumns,
} from 'naive-ui'
import { api, asList } from '../api/client'
import type { AccessLogEntry, ProxyRule, SystemLogEntry } from '../api/types'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import {
accessServiceTooltip,
buildProxyBindingIndex,
formatAccessFallback,
resolveAccessServiceLabel,
} from '../utils/accessService'
import { formatLogTime, formatMs } from '../utils/format'
import {
latencyClass,
nginxLevelTagType,
parseNginxErrorLine,
type ParsedNginxError,
displaySystemLog,
systemLevelTagType,
systemModuleTagType,
} from '../utils/logDisplay'
import { httpStatusKind } from '../utils/status'
const PAGE_SIZE = 20
const LogPagination = defineComponent({
name: 'LogPagination',
props: {
page: { type: Number, required: true },
itemCount: { type: Number, required: true },
},
emits: ['update:page'],
setup(props, { emit }) {
return () =>
h('div', { class: 'log-pagination' }, [
h(NPagination, {
page: props.page,
pageSize: PAGE_SIZE,
itemCount: props.itemCount,
showSizePicker: false,
'onUpdate:page': (p: number) => emit('update:page', p),
}),
])
},
})
const LogToolbar = defineComponent({
name: 'LogToolbar',
props: {
keyword: { type: String, default: '' },
status: { type: Number as () => number | null, default: null },
level: { type: String, default: '' },
showDomain: Boolean,
showStatus: Boolean,
showLevel: Boolean,
autoRefresh: Boolean,
},
emits: ['update:keyword', 'update:status', 'update:level', 'refresh', 'toggle-auto'],
setup(props, { emit }) {
return () =>
h('div', { class: 'toolbar' }, [
h(NInput, {
value: props.keyword,
placeholder: '关键词搜索',
clearable: true,
style: 'max-width: 220px',
'onUpdate:value': (v: string) => emit('update:keyword', v),
}),
props.showStatus
? h(NSelect, {
value: props.status,
placeholder: '状态码',
clearable: true,
style: 'width: 120px',
options: [200, 301, 400, 403, 404, 500].map((c) => ({ label: String(c), value: c })),
'onUpdate:value': (v: number | null) => emit('update:status', v),
})
: null,
props.showLevel
? h(NSelect, {
value: props.level,
placeholder: '日志级别',
clearable: true,
style: 'width: 120px',
options: [
{ label: '信息', value: 'INFO' },
{ label: '警告', value: 'WARN' },
{ label: '错误', value: 'ERROR' },
],
'onUpdate:value': (v: string) => emit('update:level', v),
})
: null,
h(NButton, { onClick: () => emit('refresh') }, () => '刷新'),
props.autoRefresh !== undefined
? h(NButton, { quaternary: true, onClick: () => emit('toggle-auto') }, () =>
props.autoRefresh ? '关闭自动刷新' : '自动刷新',
)
: null,
])
},
})
const message = useMessage()
const route = useRoute()
const LOG_TABS = ['system', 'access', 'nginx'] as const
type LogTab = (typeof LOG_TABS)[number]
function resolveTab(queryTab: unknown): LogTab {
if (queryTab === 'error' || queryTab === 'nginx') return 'nginx'
if (queryTab === 'stream') return 'access'
if (typeof queryTab === 'string' && (LOG_TABS as readonly string[]).includes(queryTab)) {
return queryTab as LogTab
}
return 'system'
}
const tab = ref(resolveTab(route.query.tab))
const pageError = ref('')
const autoRefresh = ref(false)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const accessLogs = ref<AccessLogEntry[]>([])
const proxyRules = ref<ProxyRule[]>([])
const proxyBindingIndex = computed(() => buildProxyBindingIndex(proxyRules.value))
const errorLogs = ref<ParsedNginxError[]>([])
const systemLogs = ref<SystemLogEntry[]>([])
const loadingAccess = ref(false)
const loadingError = ref(false)
const loadingSystem = ref(false)
const accessKeyword = ref('')
const accessStatus = ref<number | null>(null)
const errorKeyword = ref('')
const systemKeyword = ref('')
const systemLevel = ref('')
const accessPage = ref(1)
const errorPage = ref(1)
const systemPage = ref(1)
const filteredAccessLogs = computed(() => {
return accessLogs.value.filter((log) => {
if (accessKeyword.value) {
const kw = accessKeyword.value.toLowerCase()
const service = resolveAccessServiceLabel(log, proxyBindingIndex.value)
const fallback = formatAccessFallback(log)
if (!`${service} ${fallback} ${log.domain} ${log.path} ${log.client_ip}`.toLowerCase().includes(kw)) return false
}
if (accessStatus.value && log.status !== accessStatus.value) return false
return true
})
})
const filteredErrorLogs = computed(() => {
if (!errorKeyword.value) return errorLogs.value
const kw = errorKeyword.value.toLowerCase()
return errorLogs.value.filter((entry) => entry.raw.toLowerCase().includes(kw))
})
const filteredSystemLogs = computed(() => {
return systemLogs.value.filter((log) => {
const display = displaySystemLog(log)
if (systemLevel.value && log.level.toUpperCase() !== systemLevel.value) return false
if (
systemKeyword.value &&
!`${display.displayModule} ${log.message}`.toLowerCase().includes(systemKeyword.value.toLowerCase())
) {
return false
}
return true
})
})
function paginate<T>(items: T[], page: number) {
const start = (page - 1) * PAGE_SIZE
return items.slice(start, start + PAGE_SIZE)
}
const pagedAccessLogs = computed(() => paginate(filteredAccessLogs.value, accessPage.value))
const pagedErrorLogs = computed(() => paginate(filteredErrorLogs.value, errorPage.value))
const pagedSystemLogs = computed(() => paginate(filteredSystemLogs.value, systemPage.value))
const methodTagType = (method: string) => {
const map: Record<string, 'success' | 'info' | 'warning' | 'error' | 'default'> = {
GET: 'success',
POST: 'info',
PUT: 'warning',
DELETE: 'error',
}
return map[method] ?? 'default'
}
const statusTagType = (code: number) => {
const kind = httpStatusKind(code)
if (kind === 'success') return 'success'
if (kind === 'warning') return 'warning'
if (kind === 'error') return 'error'
return 'info'
}
const logTimeCell = (time: string) =>
h('span', { class: 'log-time' }, formatLogTime(time))
function accessRowClassName(row: AccessLogEntry) {
if (row.status >= 500) return 'log-row log-row--error'
if (row.status >= 400) return 'log-row log-row--warn'
return 'log-row'
}
function errorRowClassName(row: ParsedNginxError) {
const type = nginxLevelTagType(row.level)
if (type === 'error') return 'log-row log-row--error'
if (type === 'warning') return 'log-row log-row--warn'
return 'log-row'
}
function systemRowClassName(row: SystemLogEntry) {
const display = displaySystemLog(row)
if (display.displayLevel === '错误') return 'log-row log-row--error'
if (display.displayLevel === '警告') return 'log-row log-row--warn'
return 'log-row'
}
// FRP 日志(frpc):后端返回原始行,前端解析为 时间 / 级别 / 来源 / 消息 四列
interface FrpLogEntry { time: string; level: string; source: string; message: string }
const frpLogs = ref<FrpLogEntry[]>([])
const frpKeyword = ref('')
const frpLevel = ref('')
const frpPage = ref(1)
const loadingFrp = ref(false)
const frpPageSize = 50
const frpLevelText: Record<string, string> = { I: '信息', W: '警告', E: '错误', D: '调试' }
// 工具条的级别选项值是 INFO / WARN / ERROR,而 frpc 的级别是单字母 I/W/E/D,需转换后再比较
const frpLevelAlias: Record<string, string> = { INFO: 'I', WARN: 'W', ERROR: 'E' }
// frpc 行有两种形态:
// 2026-09-23 15:20:31.123 [I] [service.go:295] login to server success
// [web] 2026-09-20 19:38:51.771 [I] [client/control.go:174] [trace-id] [web-http] start proxy success
// 行首可能带一个 [组件] 前缀,来源优先取 [文件:行号],缺失时回落为该组件名
function parseFrpLogLine(line: string): FrpLogEntry {
const matched = line.match(
/^(?:\[([^\]]+)\]\s+)?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s*\[([A-Za-z])\]\s*(?:\[([^\]]+)\])?\s*(.*)$/,
)
if (!matched) return { time: '', level: '', source: '', message: line }
const component = matched[1] ?? ''
return {
time: matched[2],
level: matched[3].toUpperCase(),
source: matched[4] || component,
message: matched[5] ?? '',
}
}
const filteredFrpLogs = computed(() => {
const keyword = frpKeyword.value.trim().toLowerCase()
return frpLogs.value.filter((log) => {
if (frpLevel.value && frpLevelAlias[frpLevel.value] !== log.level) return false
if (keyword && !`${log.source} ${log.message}`.toLowerCase().includes(keyword)) return false
return true
})
})
const pagedFrpLogs = computed(() => {
const start = (frpPage.value - 1) * frpPageSize
return filteredFrpLogs.value.slice(start, start + frpPageSize)
})
// 与「系统日志」页签同一套渲染:时间走 logTimeCell,级别/来源用小号标签,消息单行省略并带 tooltip
const frpColumns: DataTableColumns<FrpLogEntry> = [
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
{
title: '级别',
key: 'level',
width: 80,
render: (row) => {
const label = frpLevelText[row.level] ?? (row.level || '—')
return h(NTag, { size: 'small', bordered: false, type: systemLevelTagType(label) }, () => label)
},
},
{
title: '来源',
key: 'source',
width: 170,
render: (row) =>
h(
NTag,
{
size: 'small',
bordered: false,
type: systemModuleTagType(row.source),
class: 'log-module-tag',
},
() => row.source || '—',
),
},
{
title: '消息',
key: 'message',
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'log-message' }, row.message),
},
]
function frpRowClassName(row: FrpLogEntry) {
if (row.level === 'E') return 'log-row log-row--error'
if (row.level === 'W') return 'log-row log-row--warn'
return 'log-row'
}
async function loadFrpLogs() {
loadingFrp.value = true
try {
const raw = asList(await api.getFrpLogs(500))
frpLogs.value = raw.map((line) => parseFrpLogLine(String(line)))
} catch (error) {
message.error(error instanceof Error ? error.message : '加载 FRP 日志失败')
} finally {
loadingFrp.value = false
}
}
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
{
title: '服务',
key: 'service',
width: 128,
render: (row) => {
const tooltip = accessServiceTooltip(row, proxyBindingIndex.value)
const label = resolveAccessServiceLabel(row, proxyBindingIndex.value)
return h('span', { class: 'log-service nowrap', title: tooltip }, label)
},
},
{
title: '方法',
key: 'method',
width: 68,
render: (row) => h(NTag, { size: 'small', bordered: false, type: methodTagType(row.method) }, () => row.method),
},
{
title: '路径',
key: 'path',
width: 220,
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'log-path mono nowrap', title: row.path }, row.path),
},
{
title: '状态',
key: 'status',
width: 68,
render: (row) => h(NTag, { size: 'small', bordered: false, type: statusTagType(row.status) }, () => String(row.status)),
},
{
title: '耗时',
key: 'response_time',
width: 80,
render: (row) => h('span', { class: latencyClass(row.response_time) }, formatMs(row.response_time)),
},
{
title: '来源 IP',
key: 'client_ip',
width: 140,
render: (row) => h('span', { class: 'mono nowrap', title: row.client_ip }, row.client_ip),
},
])
const errorColumns: DataTableColumns<ParsedNginxError> = [
{ title: '时间', key: 'time', width: 170, render: (row) => logTimeCell(row.time || '-') },
{
title: '级别',
key: 'level',
width: 88,
render: (row) =>
h(
NTag,
{ size: 'small', bordered: false, type: nginxLevelTagType(row.level) },
() => (row.level === 'unknown' ? '未知' : row.level.toUpperCase()),
),
},
{
title: '消息',
key: 'message',
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'log-message mono' }, row.message),
},
]
const systemColumns: DataTableColumns<SystemLogEntry> = [
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
{
title: '级别',
key: 'level',
width: 80,
render: (row) => {
const display = displaySystemLog(row)
return h(
NTag,
{ size: 'small', bordered: false, type: systemLevelTagType(display.displayLevel) },
() => display.displayLevel,
)
},
},
{
title: '模块',
key: 'module',
width: 108,
render: (row) => {
const display = displaySystemLog(row)
return h(
NTag,
{
size: 'small',
bordered: false,
type: systemModuleTagType(display.displayModule),
class: 'log-module-tag',
},
() => display.displayModule,
)
},
},
{
title: '消息',
key: 'message',
ellipsis: { tooltip: true },
render: (row) => {
const display = displaySystemLog(row)
return h('span', { class: 'log-message' }, display.message)
},
},
]
async function loadProxyRules() {
try {
proxyRules.value = asList(await api.listProxies())
} catch {
proxyRules.value = []
}
}
async function loadAccess() {
loadingAccess.value = true
try {
await loadProxyRules()
accessLogs.value = asList(await api.getAccessLogs({ limit: 100 }))
} catch (error) {
message.error(error instanceof Error ? error.message : '加载访问日志失败')
} finally {
loadingAccess.value = false
}
}
async function loadErrorLogs() {
loadingError.value = true
try {
const errors = asList(await api.getErrorLogs({ limit: 100 }))
errorLogs.value = errors.map((line) => parseNginxErrorLine(line))
} catch (error) {
message.error(error instanceof Error ? error.message : '加载错误日志失败')
} finally {
loadingError.value = false
}
}
async function loadSystemLogs() {
loadingSystem.value = true
try {
systemLogs.value = asList(await api.getSystemLogs({ limit: 100 }))
} catch (error) {
message.error(error instanceof Error ? error.message : '加载系统日志失败')
} finally {
loadingSystem.value = false
}
}
async function loadAll() {
pageError.value = ''
try {
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs()])
} catch (error) {
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
}
}
watch([accessKeyword, accessStatus], () => {
accessPage.value = 1
})
watch(errorKeyword, () => {
errorPage.value = 1
})
watch([systemKeyword, systemLevel], () => {
systemPage.value = 1
})
watch([frpKeyword, frpLevel], () => {
frpPage.value = 1
})
watch(autoRefresh, (on) => {
if (refreshTimer) clearInterval(refreshTimer)
if (on) refreshTimer = setInterval(() => loadAccess(), 10000)
})
watch(
() => route.query.tab,
(queryTab) => {
tab.value = resolveTab(queryTab)
},
)
watch(tab, (name) => {
if (name === 'access') loadAccess()
else if (name === 'nginx') loadErrorLogs()
else if (name === 'system') loadSystemLogs()
})
onMounted(() => {
tab.value = resolveTab(route.query.tab)
loadAll()
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<style scoped>
.logs-card :deep(.n-tabs-nav) {
padding: var(--fonu-space-4) var(--fonu-space-5) 0;
}
.logs-card :deep(.n-tabs-tab) {
font-size: 14px;
}
.logs-card :deep(.n-tab-pane) {
padding-top: var(--fonu-space-2);
}
.tab-hint {
margin: 0 var(--fonu-space-5) var(--fonu-space-3);
font-size: 13px;
color: var(--fonu-text-muted);
}
.log-pagination {
display: flex;
justify-content: flex-end;
padding: var(--fonu-space-4) var(--fonu-space-5);
border-top: 1px solid var(--fonu-border);
}
.logs-card :deep(.log-table .n-data-table-th) {
font-size: 13px;
font-weight: 600;
color: var(--fonu-text-secondary);
}
.logs-card :deep(.log-table .n-data-table-base-table) {
table-layout: fixed;
}
.logs-card :deep(.log-table .n-data-table-td) {
font-size: 13px;
padding-top: 10px;
padding-bottom: 10px;
}
.nowrap {
white-space: nowrap;
}
.logs-card :deep(.log-table .n-data-table-tr.log-row--error .n-data-table-td) {
background: color-mix(in srgb, #ef4444 7%, transparent);
}
.logs-card :deep(.log-table .n-data-table-tr.log-row--warn .n-data-table-td) {
background: color-mix(in srgb, #f59e0b 7%, transparent);
}
.logs-card :deep(.log-table .n-data-table-tr.log-row:hover .n-data-table-td) {
background: color-mix(in srgb, var(--fonu-brand) 5%, var(--fonu-surface));
}
.log-time {
white-space: nowrap;
color: var(--fonu-text-secondary);
font-variant-numeric: tabular-nums;
}
.log-service {
font-weight: 500;
}
.log-path {
color: var(--fonu-text-secondary);
}
.log-message {
line-height: 1.5;
}
.mono {
font-family: var(--fonu-mono);
font-size: 12px;
}
.log-duration {
font-variant-numeric: tabular-nums;
}
.log-duration--slow {
color: #d97706;
font-weight: 600;
}
.log-duration--critical {
color: #dc2626;
font-weight: 600;
}
.logs-card :deep(.log-module-tag) {
white-space: nowrap;
}
</style>