2026-09-23 09:40:59 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-09-23 17:46:21 +08:00
|
|
|
|
<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
|
2026-09-23 18:08:31 +08:00
|
|
|
|
v-if="!loadingFrp && filteredFrpLogs.length === 0"
|
|
|
|
|
|
:title="frpLogs.length === 0 ? '暂无 FRP 日志' : '没有匹配的日志'"
|
|
|
|
|
|
:description="frpLogs.length === 0 ? '启动 frpc 后,登录、隧道注册与连接错误会记录在这里。' : '换个关键词或级别再试。'"
|
2026-09-23 17:46:21 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</n-tab-pane>
|
|
|
|
|
|
|
2026-09-24 10:15:11 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
<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()
|
2026-09-24 10:15:11 +08:00
|
|
|
|
const LOG_TABS = ['system', 'frp', 'remote-nginx', 'access', 'nginx'] as const
|
2026-09-23 09:40:59 +08:00
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 17:46:21 +08:00
|
|
|
|
// 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: '调试' }
|
2026-09-23 18:08:31 +08:00
|
|
|
|
// 工具条的级别选项值是 INFO / WARN / ERROR,而 frpc 的级别是单字母 I/W/E/D,需转换后再比较
|
|
|
|
|
|
const frpLevelAlias: Record<string, string> = { INFO: 'I', WARN: 'W', ERROR: 'E' }
|
2026-09-23 17:46:21 +08:00
|
|
|
|
|
2026-09-23 18:08:31 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
// 行首可能带一个 [组件] 前缀,来源优先取 [文件:行号],缺失时回落为该组件名
|
2026-09-23 17:46:21 +08:00
|
|
|
|
function parseFrpLogLine(line: string): FrpLogEntry {
|
2026-09-23 18:08:31 +08:00
|
|
|
|
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*(.*)$/,
|
|
|
|
|
|
)
|
2026-09-23 17:46:21 +08:00
|
|
|
|
if (!matched) return { time: '', level: '', source: '', message: line }
|
2026-09-23 18:08:31 +08:00
|
|
|
|
const component = matched[1] ?? ''
|
|
|
|
|
|
return {
|
|
|
|
|
|
time: matched[2],
|
|
|
|
|
|
level: matched[3].toUpperCase(),
|
|
|
|
|
|
source: matched[4] || component,
|
|
|
|
|
|
message: matched[5] ?? '',
|
|
|
|
|
|
}
|
2026-09-23 17:46:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const filteredFrpLogs = computed(() => {
|
|
|
|
|
|
const keyword = frpKeyword.value.trim().toLowerCase()
|
|
|
|
|
|
return frpLogs.value.filter((log) => {
|
2026-09-23 18:08:31 +08:00
|
|
|
|
if (frpLevel.value && frpLevelAlias[frpLevel.value] !== log.level) return false
|
2026-09-23 17:46:21 +08:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-23 18:08:31 +08:00
|
|
|
|
// 与「系统日志」页签同一套渲染:时间走 logTimeCell,级别/来源用小号标签,消息单行省略并带 tooltip
|
2026-09-23 17:46:21 +08:00
|
|
|
|
const frpColumns: DataTableColumns<FrpLogEntry> = [
|
2026-09-23 18:08:31 +08:00
|
|
|
|
{ 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),
|
|
|
|
|
|
},
|
2026-09-23 17:46:21 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 10:15:11 +08:00
|
|
|
|
// 公网 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())
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-23 17:46:21 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
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 {
|
2026-09-23 17:46:21 +08:00
|
|
|
|
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs()])
|
2026-09-23 09:40:59 +08:00
|
|
|
|
} 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
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-23 18:08:31 +08:00
|
|
|
|
watch([frpKeyword, frpLevel], () => {
|
|
|
|
|
|
frpPage.value = 1
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-24 10:15:11 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-23 09:40:59 +08:00
|
|
|
|
</style>
|