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
923 lines
27 KiB
Vue
923 lines
27 KiB
Vue
<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>
|