fix(usage): 管理端错误请求页过滤与分列完善
- 错误请求标签补传 model/account_id/group_id 过滤(此前 loadAdminErrors 丢弃),admin handler 读取 model 查询参数走精确匹配 - 错误表格拆成 用户/API Key/账号 三独立列(上游行也显示用户),补 api_key_name/api_key_deleted, 已删除 key 显示红色「已删除」标记;i18n keyDeletedBadge 补入 errorLog 命名空间 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cf12bc521b
commit
fe8952733a
@ -110,6 +110,9 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
|
||||
filter.Source = strings.TrimSpace(c.Query("error_source"))
|
||||
filter.Query = strings.TrimSpace(c.Query("q"))
|
||||
filter.UserQuery = strings.TrimSpace(c.Query("user_query"))
|
||||
// Model 过滤:admin 走精确匹配(ModelFuzzy 默认 false,保持管理端语义)。
|
||||
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
|
||||
filter.Model = strings.TrimSpace(c.Query("model"))
|
||||
|
||||
// Force request errors: client-visible status >= 400.
|
||||
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
|
||||
@ -227,6 +230,9 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
|
||||
filter.Source = strings.TrimSpace(c.Query("error_source"))
|
||||
filter.Query = strings.TrimSpace(c.Query("q"))
|
||||
filter.UserQuery = strings.TrimSpace(c.Query("user_query"))
|
||||
// Model 过滤:admin 走精确匹配(ModelFuzzy 默认 false,保持管理端语义)。
|
||||
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
|
||||
filter.Model = strings.TrimSpace(c.Query("model"))
|
||||
|
||||
// Force request errors: client-visible status >= 400.
|
||||
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
|
||||
|
||||
@ -907,6 +907,9 @@ export interface OpsErrorLog {
|
||||
user_id?: number | null
|
||||
user_email: string
|
||||
api_key_id?: number | null
|
||||
// 关联 api_key 名称(后端 LEFT JOIN api_keys;软删保留 name,故已删 key 仍有原名)。
|
||||
api_key_name?: string
|
||||
api_key_deleted?: boolean
|
||||
account_id?: number | null
|
||||
account_name: string
|
||||
group_id?: number | null
|
||||
@ -1086,6 +1089,8 @@ export type OpsErrorListQueryParams = {
|
||||
account_id?: number | null
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
// 模型过滤:后端以 COALESCE(requested_model, model) 精确匹配(admin 路径)。
|
||||
model?: string
|
||||
|
||||
phase?: string
|
||||
error_owner?: string
|
||||
|
||||
@ -4776,6 +4776,8 @@ export default {
|
||||
group: 'Group',
|
||||
user: 'User',
|
||||
userId: 'User ID',
|
||||
apiKey: 'API Key',
|
||||
keyDeletedBadge: 'Key Deleted',
|
||||
account: 'Account',
|
||||
accountId: 'Account ID',
|
||||
status: 'Status',
|
||||
|
||||
@ -4935,6 +4935,8 @@ export default {
|
||||
group: '分组',
|
||||
user: '用户',
|
||||
userId: '用户 ID',
|
||||
apiKey: 'API Key',
|
||||
keyDeletedBadge: 'Key 已删除',
|
||||
account: '账号',
|
||||
accountId: '账号 ID',
|
||||
status: '状态码',
|
||||
|
||||
@ -647,6 +647,9 @@ const loadAdminErrors = async () => {
|
||||
end_time: toRFC3339(filters.value.end_date, true),
|
||||
user_id: filters.value.user_id ?? undefined,
|
||||
api_key_id: filters.value.api_key_id ?? undefined,
|
||||
account_id: filters.value.account_id ?? undefined,
|
||||
group_id: filters.value.group_id ?? undefined,
|
||||
model: filters.value.model || undefined,
|
||||
})
|
||||
errRows.value = resp.items
|
||||
errTotal.value = resp.total
|
||||
|
||||
@ -3,7 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import UsageView from '../UsageView.vue'
|
||||
|
||||
const { list, getStats, getSnapshotV2, getById, getModelStats } = vi.hoisted(() => {
|
||||
const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs } = vi.hoisted(() => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
@ -16,6 +16,7 @@ const { list, getStats, getSnapshotV2, getById, getModelStats } = vi.hoisted(()
|
||||
getSnapshotV2: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getModelStats: vi.fn(),
|
||||
listErrorLogs: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@ -55,6 +56,10 @@ vi.mock('@/api/admin/usage', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/ops', () => ({
|
||||
listErrorLogs,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
@ -289,3 +294,61 @@ describe('admin UsageView handleUserClick', () => {
|
||||
expect(getById).toHaveBeenCalledWith(2, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin UsageView errors tab filter forwarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
list.mockReset()
|
||||
getStats.mockReset()
|
||||
getSnapshotV2.mockReset()
|
||||
getModelStats.mockReset()
|
||||
listErrorLogs.mockReset()
|
||||
|
||||
list.mockResolvedValue({ items: [], total: 0, pages: 0 })
|
||||
getStats.mockResolvedValue({
|
||||
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
|
||||
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
|
||||
})
|
||||
getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [] })
|
||||
getModelStats.mockResolvedValue({ models: [] })
|
||||
listErrorLogs.mockResolvedValue({ items: [], total: 0, pages: 0 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('forwards model/account_id/group_id to listErrorLogs on the errors tab', async () => {
|
||||
const wrapper = mount(UsageView, {
|
||||
global: { stubs: {
|
||||
AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub,
|
||||
UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true,
|
||||
UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true,
|
||||
DateRangePicker: true, Icon: true, TokenUsageTrend: true,
|
||||
ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true,
|
||||
OpsErrorLogTable: true, OpsErrorDetailModal: true,
|
||||
} },
|
||||
})
|
||||
vi.advanceTimersByTime(120)
|
||||
await flushPromises()
|
||||
|
||||
// 模拟用户在过滤器里选择了模型/账户/分组
|
||||
const vm = wrapper.vm as any
|
||||
vm.filters.model = 'gpt-5.3-codex'
|
||||
vm.filters.account_id = 7
|
||||
vm.filters.group_id = 3
|
||||
await flushPromises()
|
||||
|
||||
// 切换到「错误请求」标签(第二个 .tab 按钮)触发 loadAdminErrors
|
||||
const tabs = wrapper.findAll('button.tab')
|
||||
await tabs[1].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(listErrorLogs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
view: 'all',
|
||||
model: 'gpt-5.3-codex',
|
||||
account_id: 7,
|
||||
group_id: 3,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -32,6 +32,12 @@
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.user') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.apiKey') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.account') }}
|
||||
</th>
|
||||
<th class="border-b border-gray-200 px-4 py-2.5 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:border-dark-700 dark:text-dark-400">
|
||||
{{ t('admin.ops.errorLog.status') }}
|
||||
</th>
|
||||
@ -45,7 +51,7 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<tr v-if="rows.length === 0">
|
||||
<td colspan="10" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
|
||||
<td colspan="12" class="py-12 text-center text-sm text-gray-400 dark:text-dark-500">
|
||||
{{ t('admin.ops.errorLog.noErrors') }}
|
||||
</td>
|
||||
</tr>
|
||||
@ -127,24 +133,40 @@
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
|
||||
<!-- User / Account -->
|
||||
<!-- User -->
|
||||
<td class="px-4 py-2">
|
||||
<template v-if="isUpstreamRow(log)">
|
||||
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.accountId') + ' ' + log.account_id" placement="top" :show-after="500">
|
||||
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ log.account_name || '-' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tooltip v-if="log.user_id" :content="t('admin.ops.errorLog.userId') + ' ' + log.user_id" placement="top" :show-after="500">
|
||||
<span class="max-w-[100px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ log.user_email || '-' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</template>
|
||||
<el-tooltip v-if="log.user_id" :content="t('admin.ops.errorLog.userId') + ' ' + log.user_id" placement="top" :show-after="500">
|
||||
<span class="block max-w-[140px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ log.user_email || '-' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
|
||||
<!-- API Key -->
|
||||
<td class="px-4 py-2">
|
||||
<div v-if="log.api_key_id || log.api_key_name" class="flex max-w-[140px] items-center gap-1">
|
||||
<span class="truncate text-xs font-medium text-gray-900 dark:text-gray-200" :title="log.api_key_name || ('#' + log.api_key_id)">
|
||||
{{ log.api_key_name || ('#' + log.api_key_id) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.api_key_deleted"
|
||||
class="flex-shrink-0 rounded px-1 py-0.5 text-[9px] font-bold ring-1 ring-inset bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30"
|
||||
>
|
||||
{{ t('admin.ops.errorLog.keyDeletedBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
|
||||
<!-- Account -->
|
||||
<td class="px-4 py-2">
|
||||
<el-tooltip v-if="log.account_id" :content="t('admin.ops.errorLog.accountId') + ' ' + log.account_id" placement="top" :show-after="500">
|
||||
<span class="block max-w-[120px] truncate text-xs font-medium text-gray-900 dark:text-gray-200">
|
||||
{{ log.account_name || '-' }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
|
||||
@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import OpsErrorLogTable from '../OpsErrorLogTable.vue'
|
||||
import zhLocale from '@/i18n/locales/zh'
|
||||
import enLocale from '@/i18n/locales/en'
|
||||
import type { OpsErrorLog } from '@/api/admin/ops'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}
|
||||
})
|
||||
|
||||
const TooltipStub = { template: '<div><slot /></div>' }
|
||||
const PaginationStub = { template: '<div class="pagination-stub" />' }
|
||||
|
||||
function mountTable(row: Partial<OpsErrorLog>) {
|
||||
const base = {
|
||||
id: 1,
|
||||
created_at: '2026-06-05T23:59:50Z',
|
||||
phase: 'upstream',
|
||||
type: '',
|
||||
error_owner: 'provider',
|
||||
error_source: 'upstream_http',
|
||||
severity: 'error',
|
||||
status_code: 529,
|
||||
platform: 'anthropic',
|
||||
model: 'claude-opus-4-8',
|
||||
resolved: false,
|
||||
client_request_id: '',
|
||||
request_id: 'req-1',
|
||||
message: 'boom',
|
||||
user_email: '',
|
||||
account_name: '',
|
||||
group_name: '',
|
||||
...row,
|
||||
} as OpsErrorLog
|
||||
|
||||
return mount(OpsErrorLogTable, {
|
||||
props: { rows: [base], total: 1, loading: false, page: 1, pageSize: 20 },
|
||||
global: { stubs: { 'el-tooltip': TooltipStub, Pagination: PaginationStub } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('OpsErrorLogTable user/api-key/account columns', () => {
|
||||
// 回归:上游错误行(phase=upstream, owner=provider)以前在单一「用户」列里只显示账号、
|
||||
// 丢失用户;现在用户/API Key/账号各占独立列,三者同时可见。
|
||||
it('renders user, api key and account in separate columns for an upstream row', () => {
|
||||
const wrapper = mountTable({
|
||||
user_id: 2,
|
||||
user_email: 'alice@test.com',
|
||||
api_key_id: 5,
|
||||
api_key_name: 'my-key',
|
||||
account_id: 9,
|
||||
account_name: 'acct-A',
|
||||
})
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('alice@test.com') // 用户列(上游行也显示用户)
|
||||
expect(text).toContain('my-key') // API Key 列
|
||||
expect(text).toContain('acct-A') // 账号列
|
||||
})
|
||||
|
||||
it('shows the deleted badge for a soft-deleted api key', () => {
|
||||
const wrapper = mountTable({
|
||||
api_key_id: 5,
|
||||
api_key_name: 'old-key',
|
||||
api_key_deleted: true,
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('old-key')
|
||||
expect(wrapper.text()).toContain('admin.ops.errorLog.keyDeletedBadge')
|
||||
})
|
||||
})
|
||||
|
||||
// 防回归:组件用 admin.ops.errorLog.* 命名空间。若 i18n 键写错命名空间(如误放到
|
||||
// errorDetail),真实 vue-i18n 会回退返回 key 本身 → 界面显示原始路径字符串。
|
||||
// 这里用真实 locale 校验键确实可解析(返回译文而非 key)。
|
||||
// 防回归:组件用 admin.ops.errorLog.* 命名空间。若键写错命名空间(如误放到
|
||||
// errorDetail),界面会显示原始路径字符串而非译文。vitest 的 vue-i18n 为 runtime-only
|
||||
// (无消息编译器,t() 对任何键都回退返回 key),故直接校验 locale 对象的命名空间含这些键。
|
||||
describe('OpsErrorLogTable i18n keys exist in the errorLog namespace', () => {
|
||||
const locales: Record<string, any> = { zh: zhLocale, en: enLocale }
|
||||
for (const [name, msgs] of Object.entries(locales)) {
|
||||
it(`has apiKey & keyDeletedBadge for ${name}`, () => {
|
||||
const errorLog = msgs?.admin?.ops?.errorLog
|
||||
expect(errorLog?.apiKey).toBeTruthy()
|
||||
expect(errorLog?.keyDeletedBadge).toBeTruthy()
|
||||
})
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user