Files
sub2api/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue
yangjianbo eaa7d899f0 fix(ops): 优化系统日志展示为可读文本
解析 extra 字段(status_code/latency_ms/method/path 等)并拼成普通文本\n表格改为 3 列并固定时间/级别宽度,详情列填满后自动换行

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 18:00:16 +08:00

507 lines
19 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.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { opsAPI, type OpsRuntimeLogConfig, type OpsSystemLog, type OpsSystemLogSinkHealth } from '@/api/admin/ops'
import Pagination from '@/components/common/Pagination.vue'
import { useAppStore } from '@/stores'
const appStore = useAppStore()
const props = withDefaults(defineProps<{
platformFilter?: string
refreshToken?: number
}>(), {
platformFilter: '',
refreshToken: 0
})
const loading = ref(false)
const logs = ref<OpsSystemLog[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const health = ref<OpsSystemLogSinkHealth>({
queue_depth: 0,
queue_capacity: 0,
dropped_count: 0,
write_failed_count: 0,
written_count: 0,
avg_write_delay_ms: 0
})
const runtimeLoading = ref(false)
const runtimeSaving = ref(false)
const runtimeConfig = reactive<OpsRuntimeLogConfig>({
level: 'info',
enable_sampling: false,
sampling_initial: 100,
sampling_thereafter: 100,
caller: true,
stacktrace_level: 'error',
retention_days: 30
})
const filters = reactive({
time_range: '1h' as '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d',
start_time: '',
end_time: '',
level: '',
component: '',
request_id: '',
client_request_id: '',
user_id: '',
account_id: '',
platform: '',
model: '',
q: ''
})
const levelBadgeClass = (level: string) => {
const v = String(level || '').toLowerCase()
if (v === 'error' || v === 'fatal') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
if (v === 'warn' || v === 'warning') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
if (v === 'debug') return 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}
const formatTime = (value: string) => {
if (!value) return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleString()
}
const getExtraString = (extra: Record<string, any> | undefined, key: string) => {
if (!extra) return ''
const v = extra[key]
if (v == null) return ''
if (typeof v === 'string') return v.trim()
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
return ''
}
const formatSystemLogDetail = (row: OpsSystemLog) => {
const parts: string[] = []
const msg = String(row.message || '').trim()
if (msg) parts.push(msg)
const extra = row.extra || {}
const statusCode = getExtraString(extra, 'status_code')
const latencyMs = getExtraString(extra, 'latency_ms')
const method = getExtraString(extra, 'method')
const path = getExtraString(extra, 'path')
const clientIP = getExtraString(extra, 'client_ip')
const protocol = getExtraString(extra, 'protocol')
const accessParts: string[] = []
if (statusCode) accessParts.push(`status=${statusCode}`)
if (latencyMs) accessParts.push(`latency_ms=${latencyMs}`)
if (method) accessParts.push(`method=${method}`)
if (path) accessParts.push(`path=${path}`)
if (clientIP) accessParts.push(`ip=${clientIP}`)
if (protocol) accessParts.push(`proto=${protocol}`)
if (accessParts.length > 0) parts.push(accessParts.join(' '))
const corrParts: string[] = []
if (row.request_id) corrParts.push(`req=${row.request_id}`)
if (row.client_request_id) corrParts.push(`client_req=${row.client_request_id}`)
if (row.user_id != null) corrParts.push(`user=${row.user_id}`)
if (row.account_id != null) corrParts.push(`acc=${row.account_id}`)
if (row.platform) corrParts.push(`platform=${row.platform}`)
if (row.model) corrParts.push(`model=${row.model}`)
if (corrParts.length > 0) parts.push(corrParts.join(' '))
const errors = getExtraString(extra, 'errors')
if (errors) parts.push(`errors=${errors}`)
const err = getExtraString(extra, 'err') || getExtraString(extra, 'error')
if (err) parts.push(`error=${err}`)
// 用空格拼接,交给 CSS 自动换行,尽量“填满再换行”。
return parts.join(' ')
}
const toRFC3339 = (value: string) => {
if (!value) return undefined
const d = new Date(value)
if (Number.isNaN(d.getTime())) return undefined
return d.toISOString()
}
const buildQuery = () => {
const query: Record<string, any> = {
page: page.value,
page_size: pageSize.value,
time_range: filters.time_range
}
if (filters.time_range === '30d') {
query.time_range = '30d'
}
if (filters.start_time) query.start_time = toRFC3339(filters.start_time)
if (filters.end_time) query.end_time = toRFC3339(filters.end_time)
if (filters.level.trim()) query.level = filters.level.trim()
if (filters.component.trim()) query.component = filters.component.trim()
if (filters.request_id.trim()) query.request_id = filters.request_id.trim()
if (filters.client_request_id.trim()) query.client_request_id = filters.client_request_id.trim()
if (filters.user_id.trim()) {
const v = Number.parseInt(filters.user_id.trim(), 10)
if (Number.isFinite(v) && v > 0) query.user_id = v
}
if (filters.account_id.trim()) {
const v = Number.parseInt(filters.account_id.trim(), 10)
if (Number.isFinite(v) && v > 0) query.account_id = v
}
if (filters.platform.trim()) query.platform = filters.platform.trim()
if (filters.model.trim()) query.model = filters.model.trim()
if (filters.q.trim()) query.q = filters.q.trim()
return query
}
const fetchLogs = async () => {
loading.value = true
try {
const res = await opsAPI.listSystemLogs(buildQuery())
logs.value = res.items || []
total.value = res.total || 0
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to fetch logs', err)
appStore.showError(err?.response?.data?.detail || '系统日志加载失败')
} finally {
loading.value = false
}
}
const fetchHealth = async () => {
try {
health.value = await opsAPI.getSystemLogSinkHealth()
} catch {
// 忽略健康数据读取失败,不影响主流程。
}
}
const loadRuntimeConfig = async () => {
runtimeLoading.value = true
try {
const cfg = await opsAPI.getRuntimeLogConfig()
runtimeConfig.level = cfg.level
runtimeConfig.enable_sampling = cfg.enable_sampling
runtimeConfig.sampling_initial = cfg.sampling_initial
runtimeConfig.sampling_thereafter = cfg.sampling_thereafter
runtimeConfig.caller = cfg.caller
runtimeConfig.stacktrace_level = cfg.stacktrace_level
runtimeConfig.retention_days = cfg.retention_days
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to load runtime log config', err)
} finally {
runtimeLoading.value = false
}
}
const saveRuntimeConfig = async () => {
runtimeSaving.value = true
try {
const saved = await opsAPI.updateRuntimeLogConfig({ ...runtimeConfig })
runtimeConfig.level = saved.level
runtimeConfig.enable_sampling = saved.enable_sampling
runtimeConfig.sampling_initial = saved.sampling_initial
runtimeConfig.sampling_thereafter = saved.sampling_thereafter
runtimeConfig.caller = saved.caller
runtimeConfig.stacktrace_level = saved.stacktrace_level
runtimeConfig.retention_days = saved.retention_days
appStore.showSuccess('日志运行时配置已生效')
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to save runtime log config', err)
appStore.showError(err?.response?.data?.detail || '保存日志配置失败')
} finally {
runtimeSaving.value = false
}
}
const resetRuntimeConfig = async () => {
const ok = window.confirm('确认回滚为启动配置env/yaml并立即生效')
if (!ok) return
runtimeSaving.value = true
try {
const saved = await opsAPI.resetRuntimeLogConfig()
runtimeConfig.level = saved.level
runtimeConfig.enable_sampling = saved.enable_sampling
runtimeConfig.sampling_initial = saved.sampling_initial
runtimeConfig.sampling_thereafter = saved.sampling_thereafter
runtimeConfig.caller = saved.caller
runtimeConfig.stacktrace_level = saved.stacktrace_level
runtimeConfig.retention_days = saved.retention_days
appStore.showSuccess('已回滚到启动日志配置')
await fetchHealth()
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to reset runtime log config', err)
appStore.showError(err?.response?.data?.detail || '回滚日志配置失败')
} finally {
runtimeSaving.value = false
}
}
const cleanupCurrentFilter = async () => {
const ok = window.confirm('确认按当前筛选条件清理系统日志?该操作不可撤销。')
if (!ok) return
try {
const payload = {
start_time: toRFC3339(filters.start_time),
end_time: toRFC3339(filters.end_time),
level: filters.level.trim() || undefined,
component: filters.component.trim() || undefined,
request_id: filters.request_id.trim() || undefined,
client_request_id: filters.client_request_id.trim() || undefined,
user_id: filters.user_id.trim() ? Number.parseInt(filters.user_id.trim(), 10) : undefined,
account_id: filters.account_id.trim() ? Number.parseInt(filters.account_id.trim(), 10) : undefined,
platform: filters.platform.trim() || undefined,
model: filters.model.trim() || undefined,
q: filters.q.trim() || undefined
}
const res = await opsAPI.cleanupSystemLogs(payload)
appStore.showSuccess(`清理完成,删除 ${res.deleted || 0} 条日志`)
page.value = 1
await Promise.all([fetchLogs(), fetchHealth()])
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to cleanup logs', err)
appStore.showError(err?.response?.data?.detail || '清理系统日志失败')
}
}
const resetFilters = () => {
filters.time_range = '1h'
filters.start_time = ''
filters.end_time = ''
filters.level = ''
filters.component = ''
filters.request_id = ''
filters.client_request_id = ''
filters.user_id = ''
filters.account_id = ''
filters.platform = props.platformFilter || ''
filters.model = ''
filters.q = ''
page.value = 1
fetchLogs()
}
watch(() => props.platformFilter, (v) => {
if (v && !filters.platform) {
filters.platform = v
page.value = 1
fetchLogs()
}
})
watch(() => props.refreshToken, () => {
fetchLogs()
fetchHealth()
})
const onPageChange = (next: number) => {
page.value = next
fetchLogs()
}
const onPageSizeChange = (next: number) => {
pageSize.value = next
page.value = 1
fetchLogs()
}
const applyFilters = () => {
page.value = 1
fetchLogs()
}
const hasData = computed(() => logs.value.length > 0)
onMounted(async () => {
if (props.platformFilter) {
filters.platform = props.platformFilter
}
await Promise.all([fetchLogs(), fetchHealth(), loadRuntimeConfig()])
})
</script>
<template>
<section class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-900/60">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-bold text-gray-900 dark:text-white">系统日志</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">默认按最新时间倒序支持筛选搜索与按条件清理</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">队列 {{ health.queue_depth }}/{{ health.queue_capacity }}</span>
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">写入 {{ health.written_count }}</span>
<span class="rounded-md bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">丢弃 {{ health.dropped_count }}</span>
<span class="rounded-md bg-red-100 px-2 py-1 text-red-700 dark:bg-red-900/30 dark:text-red-300">失败 {{ health.write_failed_count }}</span>
</div>
</div>
<div class="mb-4 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-800/70">
<div class="mb-2 flex items-center justify-between">
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">运行时日志配置实时生效</div>
<span v-if="runtimeLoading" class="text-xs text-gray-500">加载中...</span>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-6">
<label class="text-xs text-gray-600 dark:text-gray-300">
级别
<select v-model="runtimeConfig.level" class="input mt-1">
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
堆栈阈值
<select v-model="runtimeConfig.stacktrace_level" class="input mt-1">
<option value="none">none</option>
<option value="error">error</option>
<option value="fatal">fatal</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
采样初始
<input v-model.number="runtimeConfig.sampling_initial" type="number" min="1" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
采样后续
<input v-model.number="runtimeConfig.sampling_thereafter" type="number" min="1" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
保留天数
<input v-model.number="runtimeConfig.retention_days" type="number" min="1" max="3650" class="input mt-1" />
</label>
<div class="flex items-end gap-2">
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input v-model="runtimeConfig.caller" type="checkbox" />
caller
</label>
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input v-model="runtimeConfig.enable_sampling" type="checkbox" />
sampling
</label>
<button type="button" class="btn btn-primary btn-sm" :disabled="runtimeSaving" @click="saveRuntimeConfig">
{{ runtimeSaving ? '保存中...' : '保存并生效' }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="runtimeSaving" @click="resetRuntimeConfig">
回滚默认值
</button>
</div>
</div>
<p v-if="health.last_error" class="mt-2 text-xs text-red-600 dark:text-red-400">最近写入错误{{ health.last_error }}</p>
</div>
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-5">
<label class="text-xs text-gray-600 dark:text-gray-300">
时间范围
<select v-model="filters.time_range" class="input mt-1">
<option value="5m">5m</option>
<option value="30m">30m</option>
<option value="1h">1h</option>
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="7d">7d</option>
<option value="30d">30d</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
开始时间可选
<input v-model="filters.start_time" type="datetime-local" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
结束时间可选
<input v-model="filters.end_time" type="datetime-local" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
级别
<select v-model="filters.level" class="input mt-1">
<option value="">全部</option>
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
组件
<input v-model="filters.component" type="text" class="input mt-1" placeholder="如 http.access" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
request_id
<input v-model="filters.request_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
client_request_id
<input v-model="filters.client_request_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
user_id
<input v-model="filters.user_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
account_id
<input v-model="filters.account_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
平台
<input v-model="filters.platform" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
模型
<input v-model="filters.model" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
关键词
<input v-model="filters.q" type="text" class="input mt-1" placeholder="消息/request_id" />
</label>
</div>
<div class="mb-3 flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm" @click="applyFilters">查询</button>
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">重置</button>
<button type="button" class="btn btn-danger btn-sm" @click="cleanupCurrentFilter">按当前筛选清理</button>
<button type="button" class="btn btn-secondary btn-sm" @click="fetchHealth">刷新健康指标</button>
</div>
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
<div v-if="loading" class="px-4 py-8 text-center text-sm text-gray-500">加载中...</div>
<div v-else-if="!hasData" class="px-4 py-8 text-center text-sm text-gray-500">暂无系统日志</div>
<div v-else class="overflow-auto">
<table class="min-w-full table-fixed divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-900">
<tr>
<th class="w-[170px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">时间</th>
<th class="w-[80px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">级别</th>
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">日志详细信息</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-800">
<tr v-for="row in logs" :key="row.id" class="align-top">
<td class="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{{ formatTime(row.created_at) }}</td>
<td class="px-3 py-2 text-xs">
<span class="inline-flex rounded-full px-2 py-0.5 font-semibold" :class="levelBadgeClass(row.level)">
{{ row.level }}
</span>
</td>
<td class="px-3 py-2 text-xs text-gray-700 dark:text-gray-300 whitespace-normal break-all">
{{ formatSystemLogDetail(row) }}
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total="total"
:page="page"
:page-size="pageSize"
:page-size-options="[10, 20, 50, 100, 200]"
@update:page="onPageChange"
@update:page-size="onPageSizeChange"
/>
</div>
</section>
</template>