mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-19 22:34:46 +08:00
merge: 合并上游 v0.1.86 到 main 分支
This commit is contained in:
@@ -84,9 +84,25 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Row: OpenAI Token Stats -->
|
||||
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6">
|
||||
<OpsOpenAITokenStatsCard
|
||||
:platform-filter="platform"
|
||||
:group-id-filter="groupId"
|
||||
:refresh-token="dashboardRefreshToken"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Alert Events -->
|
||||
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
|
||||
|
||||
<!-- System Logs -->
|
||||
<OpsSystemLogTable
|
||||
v-if="opsEnabled && !(loading && !hasLoadedOnce)"
|
||||
:platform-filter="platform"
|
||||
:refresh-token="dashboardRefreshToken"
|
||||
/>
|
||||
|
||||
<!-- Settings Dialog (hidden in fullscreen mode) -->
|
||||
<template v-if="!isFullscreen">
|
||||
<OpsSettingsDialog :show="showSettingsDialog" @close="showSettingsDialog = false" @saved="onSettingsSaved" />
|
||||
@@ -148,6 +164,8 @@ import OpsLatencyChart from './components/OpsLatencyChart.vue'
|
||||
import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue'
|
||||
import OpsSwitchRateTrendChart from './components/OpsSwitchRateTrendChart.vue'
|
||||
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
|
||||
import OpsOpenAITokenStatsCard from './components/OpsOpenAITokenStatsCard.vue'
|
||||
import OpsSystemLogTable from './components/OpsSystemLogTable.vue'
|
||||
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
|
||||
import OpsSettingsDialog from './components/OpsSettingsDialog.vue'
|
||||
import OpsAlertRulesCard from './components/OpsAlertRulesCard.vue'
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { opsAPI, type OpsOpenAITokenStatsResponse, type OpsOpenAITokenStatsTimeRange } from '@/api/admin/ops'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
platformFilter?: string
|
||||
groupIdFilter?: number | null
|
||||
refreshToken: number
|
||||
}
|
||||
|
||||
type ViewMode = 'topn' | 'pagination'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
platformFilter: '',
|
||||
groupIdFilter: null
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const response = ref<OpsOpenAITokenStatsResponse | null>(null)
|
||||
|
||||
const timeRange = ref<OpsOpenAITokenStatsTimeRange>('30d')
|
||||
const viewMode = ref<ViewMode>('topn')
|
||||
const topN = ref<number>(20)
|
||||
const page = ref<number>(1)
|
||||
const pageSize = ref<number>(20)
|
||||
|
||||
const items = computed(() => response.value?.items ?? [])
|
||||
const total = computed(() => response.value?.total ?? 0)
|
||||
const totalPages = computed(() => {
|
||||
if (viewMode.value !== 'pagination') return 1
|
||||
const size = pageSize.value > 0 ? pageSize.value : 20
|
||||
return Math.max(1, Math.ceil(total.value / size))
|
||||
})
|
||||
|
||||
const timeRangeOptions = computed(() => [
|
||||
{ value: '30m', label: t('admin.ops.timeRange.30m') },
|
||||
{ value: '1h', label: t('admin.ops.timeRange.1h') },
|
||||
{ value: '1d', label: t('admin.ops.timeRange.1d') },
|
||||
{ value: '15d', label: t('admin.ops.timeRange.15d') },
|
||||
{ value: '30d', label: t('admin.ops.timeRange.30d') }
|
||||
])
|
||||
|
||||
const viewModeOptions = computed(() => [
|
||||
{ value: 'topn', label: t('admin.ops.openaiTokenStats.viewModeTopN') },
|
||||
{ value: 'pagination', label: t('admin.ops.openaiTokenStats.viewModePagination') }
|
||||
])
|
||||
|
||||
const topNOptions = computed(() => [
|
||||
{ value: 10, label: 'Top 10' },
|
||||
{ value: 20, label: 'Top 20' },
|
||||
{ value: 50, label: 'Top 50' },
|
||||
{ value: 100, label: 'Top 100' }
|
||||
])
|
||||
|
||||
const pageSizeOptions = computed(() => [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 100, label: '100' }
|
||||
])
|
||||
|
||||
function formatRate(v?: number | null): string {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return '-'
|
||||
return v.toFixed(2)
|
||||
}
|
||||
|
||||
function formatInt(v?: number | null): string {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return '-'
|
||||
return formatNumber(Math.round(v))
|
||||
}
|
||||
|
||||
function buildParams() {
|
||||
const params: Record<string, any> = {
|
||||
time_range: timeRange.value,
|
||||
platform: props.platformFilter || undefined,
|
||||
group_id: typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0 ? props.groupIdFilter : undefined
|
||||
}
|
||||
|
||||
if (viewMode.value === 'topn') {
|
||||
params.top_n = topN.value
|
||||
} else {
|
||||
params.page = page.value
|
||||
params.page_size = pageSize.value
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
response.value = await opsAPI.getOpenAITokenStats(buildParams())
|
||||
// 防御:若 total 变化导致当前页超出最大页,则回退到末页并重新拉取一次。
|
||||
if (viewMode.value === 'pagination' && page.value > totalPages.value) {
|
||||
page.value = totalPages.value
|
||||
response.value = await opsAPI.getOpenAITokenStats(buildParams())
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[OpsOpenAITokenStatsCard] Failed to load data', err)
|
||||
response.value = null
|
||||
errorMessage.value = err?.message || t('admin.ops.openaiTokenStats.failedToLoad')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
timeRange: timeRange.value,
|
||||
viewMode: viewMode.value,
|
||||
topN: topN.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
platform: props.platformFilter,
|
||||
groupId: props.groupIdFilter,
|
||||
refreshToken: props.refreshToken
|
||||
}),
|
||||
(next, prev) => {
|
||||
// 避免“筛选变化 -> 重置页码 -> 触发两次请求”:
|
||||
// 先只重置页码,等待下一次 watch(仅 page 变化)再发起请求。
|
||||
const filtersChanged = !prev ||
|
||||
next.timeRange !== prev.timeRange ||
|
||||
next.viewMode !== prev.viewMode ||
|
||||
next.pageSize !== prev.pageSize ||
|
||||
next.platform !== prev.platform ||
|
||||
next.groupId !== prev.groupId
|
||||
|
||||
if (next.viewMode === 'pagination' && filtersChanged && next.page !== 1) {
|
||||
page.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
void loadData()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function onPrevPage() {
|
||||
if (viewMode.value !== 'pagination') return
|
||||
if (page.value > 1) page.value -= 1
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (viewMode.value !== 'pagination') return
|
||||
if (page.value < totalPages.value) page.value += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card p-4 md:p-5">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ t('admin.ops.openaiTokenStats.title') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="w-36">
|
||||
<Select v-model="timeRange" :options="timeRangeOptions" />
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<Select v-model="viewMode" :options="viewModeOptions" />
|
||||
</div>
|
||||
<div v-if="viewMode === 'topn'" class="w-28">
|
||||
<Select v-model="topN" :options="topNOptions" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="w-24">
|
||||
<Select v-model="pageSize" :options="pageSizeOptions" />
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="loading || page <= 1"
|
||||
@click="onPrevPage"
|
||||
>
|
||||
{{ t('admin.ops.openaiTokenStats.prevPage') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="loading || page >= totalPages"
|
||||
@click="onNextPage"
|
||||
>
|
||||
{{ t('admin.ops.openaiTokenStats.nextPage') }}
|
||||
</button>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.openaiTokenStats.pageInfo', { page, total: totalPages }) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="mb-4 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.loadingText') }}
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="items.length === 0"
|
||||
:title="t('common.noData')"
|
||||
:description="t('admin.ops.openaiTokenStats.empty')"
|
||||
/>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-xs md:text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-gray-400">
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.model') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestCount') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgTokensPerSec') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgFirstTokenMs') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.totalOutputTokens') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgDurationMs') }}</th>
|
||||
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestsWithFirstToken') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in items"
|
||||
:key="row.model"
|
||||
class="border-b border-gray-100 text-gray-700 dark:border-dark-800 dark:text-gray-200"
|
||||
>
|
||||
<td class="px-2 py-2 font-medium">{{ row.model }}</td>
|
||||
<td class="px-2 py-2">{{ formatInt(row.request_count) }}</td>
|
||||
<td class="px-2 py-2">{{ formatRate(row.avg_tokens_per_sec) }}</td>
|
||||
<td class="px-2 py-2">{{ formatRate(row.avg_first_token_ms) }}</td>
|
||||
<td class="px-2 py-2">{{ formatInt(row.total_output_tokens) }}</td>
|
||||
<td class="px-2 py-2">{{ formatInt(row.avg_duration_ms) }}</td>
|
||||
<td class="px-2 py-2">{{ formatInt(row.requests_with_first_token) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="viewMode === 'topn'" class="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.openaiTokenStats.totalModels', { total }) }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
506
frontend/src/views/admin/ops/components/OpsSystemLogTable.vue
Normal file
506
frontend/src/views/admin/ops/components/OpsSystemLogTable.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<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>
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import OpsOpenAITokenStatsCard from '../OpsOpenAITokenStatsCard.vue'
|
||||
|
||||
const mockGetOpenAITokenStats = vi.fn()
|
||||
|
||||
vi.mock('@/api/admin/ops', () => ({
|
||||
opsAPI: {
|
||||
getOpenAITokenStats: (...args: any[]) => mockGetOpenAITokenStats(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, any>) => {
|
||||
if (key === 'admin.ops.openaiTokenStats.pageInfo' && params) {
|
||||
return `第 ${params.page}/${params.total} 页`
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
name: 'SelectControlStub',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div class="select-stub" />',
|
||||
})
|
||||
|
||||
const EmptyStateStub = defineComponent({
|
||||
name: 'EmptyState',
|
||||
props: {
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
},
|
||||
template: '<div class="empty-state">{{ title }}|{{ description }}</div>',
|
||||
})
|
||||
|
||||
const sampleResponse = {
|
||||
time_range: '30d' as const,
|
||||
start_time: '2026-01-01T00:00:00Z',
|
||||
end_time: '2026-01-31T00:00:00Z',
|
||||
platform: 'openai',
|
||||
group_id: 7,
|
||||
items: [
|
||||
{
|
||||
model: 'gpt-4o-mini',
|
||||
request_count: 12,
|
||||
avg_tokens_per_sec: 22.5,
|
||||
avg_first_token_ms: 123.45,
|
||||
total_output_tokens: 1234,
|
||||
avg_duration_ms: 321,
|
||||
requests_with_first_token: 10,
|
||||
},
|
||||
],
|
||||
total: 40,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
top_n: null,
|
||||
}
|
||||
|
||||
describe('OpsOpenAITokenStatsCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('默认加载并透传 platform/group 过滤,支持时间窗口切换', async () => {
|
||||
mockGetOpenAITokenStats.mockResolvedValue(sampleResponse)
|
||||
|
||||
const wrapper = mount(OpsOpenAITokenStatsCard, {
|
||||
props: {
|
||||
platformFilter: 'openai',
|
||||
groupIdFilter: 7,
|
||||
refreshToken: 0,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Select: SelectStub,
|
||||
EmptyState: EmptyStateStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
time_range: '30d',
|
||||
platform: 'openai',
|
||||
group_id: 7,
|
||||
top_n: 20,
|
||||
})
|
||||
)
|
||||
|
||||
const selects = wrapper.findAllComponents(SelectStub)
|
||||
await selects[0].vm.$emit('update:modelValue', '1h')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
time_range: '1h',
|
||||
platform: 'openai',
|
||||
group_id: 7,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('支持分页与 TopN 模式切换并按参数请求', async () => {
|
||||
mockGetOpenAITokenStats.mockImplementation(async (params: Record<string, any>) => ({
|
||||
...sampleResponse,
|
||||
time_range: params.time_range ?? '30d',
|
||||
page: params.page ?? 1,
|
||||
page_size: params.page_size ?? 20,
|
||||
top_n: params.top_n ?? null,
|
||||
total: 40,
|
||||
}))
|
||||
|
||||
const wrapper = mount(OpsOpenAITokenStatsCard, {
|
||||
props: {
|
||||
refreshToken: 0,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Select: SelectStub,
|
||||
EmptyState: EmptyStateStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
let selects = wrapper.findAllComponents(SelectStub)
|
||||
await selects[1].vm.$emit('update:modelValue', 'pagination')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
await buttons[1].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page: 2,
|
||||
page_size: 20,
|
||||
})
|
||||
)
|
||||
|
||||
selects = wrapper.findAllComponents(SelectStub)
|
||||
await selects[1].vm.$emit('update:modelValue', 'topn')
|
||||
await flushPromises()
|
||||
selects = wrapper.findAllComponents(SelectStub)
|
||||
await selects[2].vm.$emit('update:modelValue', 50)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
top_n: 50,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('接口返回空数据时显示空态', async () => {
|
||||
mockGetOpenAITokenStats.mockResolvedValue({
|
||||
...sampleResponse,
|
||||
items: [],
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const wrapper = mount(OpsOpenAITokenStatsCard, {
|
||||
props: { refreshToken: 0 },
|
||||
global: {
|
||||
stubs: {
|
||||
Select: SelectStub,
|
||||
EmptyState: EmptyStateStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.empty-state').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('接口异常时显示错误提示', async () => {
|
||||
mockGetOpenAITokenStats.mockRejectedValue(new Error('加载失败'))
|
||||
|
||||
const wrapper = mount(OpsOpenAITokenStatsCard, {
|
||||
props: { refreshToken: 0 },
|
||||
global: {
|
||||
stubs: {
|
||||
Select: SelectStub,
|
||||
EmptyState: EmptyStateStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('加载失败')
|
||||
})
|
||||
})
|
||||
@@ -17,5 +17,8 @@ export type {
|
||||
OpsMetricThresholds,
|
||||
OpsAdvancedSettings,
|
||||
OpsDataRetentionSettings,
|
||||
OpsAggregationSettings
|
||||
OpsAggregationSettings,
|
||||
OpsRuntimeLogConfig,
|
||||
OpsSystemLog,
|
||||
OpsSystemLogSinkHealth
|
||||
} from '@/api/admin/ops'
|
||||
|
||||
Reference in New Issue
Block a user