Files
-Fonu/web/src/views/LogsView.vue
T
mrlee 2f59f5adc4 feat(agent): agent 0.10.0 新增资源/日志/配置回读端点,前端接入 VPS 资源与公网 Nginx 日志(v1.2.4)
agent 0.10.0:新增 GET /api/v1/metrics(资源采集,复用 internal/sysinfo;CPU 两次采样差值 + 1 秒内复用上次结果)、GET /api/v1/logs/nginx(日志尾部,路径白名单来自 nginx -T / 环境变量 / 默认目录,32KB 块倒读、单行 4KB 截断、上限 2000 行)、GET /api/v1/frps/config(frps.toml 回读,256KB 上限)

internal/sysinfo:补 LoadAvg 与 UptimeSeconds(Linux /proc/loadavg 与 /proc/uptime、Windows GetTickCount64、其它平台空值)

应用侧:AgentClient 三个只读方法;request 错误改为带状态码的 statusError,新增 ErrAgentEndpointMissing,把 404/405 统一转成「agent 版本过低需 ≥ 0.10.0」提示;补 service/handler/router

前端:公网 Agent 页「VPS 资源」横排带(占满环境与依赖整行);日志中心「公网 Nginx」页签;frps 同源配置弹窗「比对 VPS 实际文件」

二进制:内嵌 fonu-agent-linux-amd64 / arm64 用新代码重编;VERSION → 1.2.4
2026-09-24 10:15:11 +08:00

923 lines
27 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="remote-nginx" tab="公网 Nginx">
<LogToolbar v-model:keyword="nginxKeyword" @refresh="loadRemoteNginxLogs" />
<div class="remote-nginx-bar">
<n-select
v-model:value="nginxTargetId"
:options="nginxTargetOptions"
placeholder="选择服务端"
size="small"
class="remote-nginx-bar__server"
/>
<n-select v-model:value="nginxKind" :options="nginxKindOptions" size="small" style="width: 120px" />
<n-select v-model:value="nginxLines" :options="nginxLineOptions" size="small" style="width: 110px" />
<span v-if="nginxPath" class="tab-hint mono remote-nginx-bar__path">
{{ nginxPath }}{{ nginxTruncated ? '(仅显示尾部)' : '' }}
</span>
</div>
<n-data-table
v-if="filteredNginxLines.length > 0"
class="log-table"
:columns="nginxColumns"
:data="pagedNginxLines"
:loading="nginxLoading"
:bordered="false"
/>
<LogPagination
v-if="filteredNginxLines.length > 0"
v-model:page="nginxPage"
:item-count="filteredNginxLines.length"
/>
<EmptyState
v-if="!nginxLoading && filteredNginxLines.length === 0"
title="暂无公网 Nginx 日志"
:description="nginxError || 'VPS 上产生访问后,这里会显示 Nginx 的访问 / 错误日志(需 agent ≥ 0.10.0)。'"
/>
</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', 'frp', 'remote-nginx', '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'
}
// 公网 Nginx 日志(VPS 侧,需 agent ≥ 0.10.0):路径由 agent 从 nginx -T 解析,前端只展示原始行
const nginxTargets = ref<import('../api/types').FrpServer[]>([])
const nginxTargetId = ref<number | null>(null)
const nginxKind = ref<'access' | 'error'>('access')
const nginxLines = ref(200)
const nginxRaw = ref<string[]>([])
const nginxPath = ref('')
const nginxTruncated = ref(false)
const nginxLoading = ref(false)
const nginxError = ref('')
const nginxKeyword = ref('')
const nginxPage = ref(1)
const nginxPageSize = 50
const nginxTargetOptions = computed(() =>
nginxTargets.value.map((item) => ({ label: item.name || item.agent_url || `服务端 #${item.id}`, value: item.id })),
)
const nginxKindOptions = [
{ label: '访问日志', value: 'access' },
{ label: '错误日志', value: 'error' },
]
const nginxLineOptions = [
{ label: '200 行', value: 200 },
{ label: '500 行', value: 500 },
{ label: '2000 行', value: 2000 },
]
const filteredNginxLines = computed(() => {
const rows = nginxRaw.value.map((line) => ({ line }))
const keyword = nginxKeyword.value.trim().toLowerCase()
if (!keyword) return rows
return rows.filter((row) => row.line.toLowerCase().includes(keyword))
})
const pagedNginxLines = computed(() => {
const start = (nginxPage.value - 1) * nginxPageSize
return filteredNginxLines.value.slice(start, start + nginxPageSize)
})
const nginxColumns: DataTableColumns<{ line: string }> = [
{
title: '日志',
key: 'line',
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'log-message' }, row.line),
},
]
async function loadNginxTargets() {
try {
nginxTargets.value = asList(await api.listFrpServers()).filter((item) => item.agent_configured)
} catch {
nginxTargets.value = []
}
if (nginxTargetId.value === null || !nginxTargets.value.some((item) => item.id === nginxTargetId.value)) {
nginxTargetId.value = nginxTargets.value[0]?.id ?? null
}
}
async function loadRemoteNginxLogs() {
if (nginxTargetId.value === null) {
nginxRaw.value = []
nginxError.value = '没有已配置 Agent 的服务端'
return
}
nginxLoading.value = true
nginxError.value = ''
try {
const result = await api.getFrpAgentNginxLogs(nginxTargetId.value, nginxKind.value, nginxLines.value)
nginxRaw.value = result.lines ?? []
nginxPath.value = result.path ?? ''
nginxTruncated.value = result.truncated ?? false
nginxPage.value = 1
} catch (error) {
nginxRaw.value = []
nginxError.value = error instanceof Error ? error.message : '读取公网 Nginx 日志失败'
} finally {
nginxLoading.value = false
}
}
watch([nginxTargetId, nginxKind, nginxLines], () => {
nginxPage.value = 1
if (nginxTargetId.value !== null) void loadRemoteNginxLogs()
})
watch(nginxKeyword, () => {
nginxPage.value = 1
})
// 切到本页签时才拉服务端列表与日志,避免影响其它页签的首屏
watch(tab, (value) => {
if (value === 'remote-nginx' && nginxTargets.value.length === 0) {
void loadNginxTargets().then(() => loadRemoteNginxLogs())
}
})
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;
}
.remote-nginx-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--fonu-space-3);
margin-bottom: var(--fonu-space-2);
}
.remote-nginx-bar__server {
width: 200px;
}
.remote-nginx-bar__path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 420px;
}
</style>