From 5d8bdfd653ae4652ddbc5a38699ae9f561e9b925 Mon Sep 17 00:00:00 2001 From: tbphp Date: Sun, 26 Jul 2026 01:05:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(monitor):=20=E5=AE=9E=E7=8E=B0=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=97=A5=E5=BF=97=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/monitor/LogDetailDrawer.test.ts | 183 ++++++ web/src/features/monitor/LogDetailDrawer.vue | 354 +++++++++++ web/src/features/monitor/LogsTab.test.ts | 554 ++++++++++++++++++ web/src/features/monitor/LogsTab.vue | 545 +++++++++++++++++ web/src/features/monitor/MonitorView.test.ts | 4 + web/src/features/monitor/MonitorView.vue | 5 +- web/src/features/monitor/log-filters.test.ts | 145 +++++ web/src/features/monitor/log-filters.ts | 168 ++++++ web/src/features/monitor/monitor-route.ts | 7 +- web/src/i18n/locales/en-US.ts | 113 ++++ web/src/i18n/locales/ja-JP.ts | 114 ++++ web/src/i18n/locales/zh-CN.ts | 113 ++++ 12 files changed, 2303 insertions(+), 2 deletions(-) create mode 100644 web/src/features/monitor/LogDetailDrawer.test.ts create mode 100644 web/src/features/monitor/LogDetailDrawer.vue create mode 100644 web/src/features/monitor/LogsTab.test.ts create mode 100644 web/src/features/monitor/LogsTab.vue create mode 100644 web/src/features/monitor/log-filters.test.ts create mode 100644 web/src/features/monitor/log-filters.ts diff --git a/web/src/features/monitor/LogDetailDrawer.test.ts b/web/src/features/monitor/LogDetailDrawer.test.ts new file mode 100644 index 00000000..20d6956a --- /dev/null +++ b/web/src/features/monitor/LogDetailDrawer.test.ts @@ -0,0 +1,183 @@ +import { QueryClient } from '@tanstack/vue-query' +import { flushPromises } from '@vue/test-utils' + +import type { ApiClient } from '@/api/client' +import type { + FailureCategory, + RequestLogAction, + RequestLogAttemptDto, + RequestLogItemDto, +} from '@/api/control/request-logs' +import { mountApp } from '@/test/mount-app' + +import LogDetailDrawer from './LogDetailDrawer.vue' + +const requestID = 'a4d4e121-8ac3-4df4-8ceb-63b10ddc6173' + +function attempt( + sequence: number, + overrides: Partial = {}, +): RequestLogAttemptDto { + return { + sequence, + group_id: 7, + group_name: 'Historical Group', + key_id: 21, + key_mask: 'sk-up…safe', + upstream_model: 'gpt-upstream', + status_code: 429, + duration_ms: 40, + failure_category: 'rate_limited', + action: 'retry', + will_retry: true, + error_code: 'rate_limit', + error_summary: 'bounded upstream summary', + committed: false, + ...overrides, + } +} + +function logFixture(overrides: Partial = {}): RequestLogItemDto { + return { + request_id: requestID, + completed_at: '2026-07-25T10:00:01Z', + access_key: { id: 12, name: 'client', deleted: false }, + protocol: 'openai', + client_model: 'gpt-client', + upstream_model: 'gpt-upstream', + status: 'error', + status_code: 502, + duration_ms: 125, + error_code: 'upstream_error', + error_summary: '', + affinity_hit: false, + attempts: [ + attempt(3, { group_name: 'Third Group', key_mask: 'sk-up…third' }), + attempt(1), + attempt(2, { group_name: 'Second Group', key_mask: 'sk-up…second' }), + ], + ...overrides, + } +} + +async function mountDrawer(log = logFixture()) { + const mounted = await mountApp(LogDetailDrawer, { + api: { request: vi.fn() as ApiClient['request'] }, + queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }), + path: '/monitor?tab=logs', + locale: 'en-US', + mounting: { + props: { open: true, log }, + attachTo: document.body, + }, + }) + await flushPromises() + return mounted +} + +function bodyElement(selector: string): T { + const element = document.body.querySelector(selector) + if (!element) throw new Error(`Missing ${selector}`) + return element +} + +describe('LogDetailDrawer', () => { + it('renders ordered safe details with Request ID as the only copy target and a minimal Inspector link', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + + await mountDrawer() + + expect( + [...document.body.querySelectorAll('[data-test^="log-attempt-"]')].map( + (element) => element.dataset.test, + ), + ).toEqual(['log-attempt-1', 'log-attempt-2', 'log-attempt-3']) + expect(bodyElement('[data-test="log-attempt-1"]').textContent).toContain('Historical Group') + expect(bodyElement('[data-test="log-attempt-1"]').textContent).toContain('sk-up…safe') + expect(document.body.textContent).toContain( + '', + ) + expect(document.body.textContent).toContain( + 'bounded upstream summary', + ) + expect(document.body.querySelector('script[data-canary="log-summary"]')).toBeNull() + expect(document.body.querySelector('b[data-canary="summary-html"]')).toBeNull() + + const copyButtons = [ + ...document.body.querySelectorAll('button[aria-label^="Copy"]'), + ] + expect(copyButtons).toHaveLength(1) + expect(copyButtons[0]?.getAttribute('aria-label')).toBe('Copy request ID') + copyButtons[0]?.click() + await flushPromises() + expect(writeText).toHaveBeenCalledWith(requestID) + expect(document.body.textContent).not.toContain('Copy all') + + expect( + bodyElement('[data-test="log-inspector-link"]').getAttribute('href'), + ).toBe('/monitor?tab=inspector&protocol=openai&external_model=gpt-client&access_key_id=12') + expect(document.body.querySelector('time')?.getAttribute('datetime')).toBe( + '2026-07-25T10:00:01Z', + ) + }) + + it('maps all failure categories and actions with one safe unknown fallback', async () => { + const categories: FailureCategory[] = [ + 'ok', + 'rate_limited', + 'model_unavailable', + 'invalid_key', + 'upstream_host_error', + 'client_error', + 'downstream_cancel', + 'ambiguous', + ] + const actions: RequestLogAction[] = [ + 'terminate', + 'retry', + 'cooldown_key', + 'fail_key', + 'skip_group', + ] + const attempts = categories.map((failureCategory, index) => + attempt(index + 1, { + failure_category: failureCategory, + action: actions[index % actions.length], + }), + ) + attempts.push( + attempt(9, { + failure_category: 'future-secret-category' as FailureCategory, + action: 'future-secret-action' as RequestLogAction, + }), + ) + + await mountDrawer(logFixture({ attempts })) + + const rendered = document.body.textContent ?? '' + for (const label of [ + 'OK', + 'Rate limited', + 'Model unavailable', + 'Invalid key', + 'Upstream host error', + 'Client error', + 'Downstream canceled', + 'Ambiguous', + 'Terminate', + 'Retry', + 'Cooldown key', + 'Fail key', + 'Skip Group', + 'Unknown category', + 'Unknown action', + ]) { + expect(rendered).toContain(label) + } + expect(rendered).not.toContain('future-secret') + }) +}) diff --git a/web/src/features/monitor/LogDetailDrawer.vue b/web/src/features/monitor/LogDetailDrawer.vue new file mode 100644 index 00000000..c344e86f --- /dev/null +++ b/web/src/features/monitor/LogDetailDrawer.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/web/src/features/monitor/LogsTab.test.ts b/web/src/features/monitor/LogsTab.test.ts new file mode 100644 index 00000000..a0672f4d --- /dev/null +++ b/web/src/features/monitor/LogsTab.test.ts @@ -0,0 +1,554 @@ +import { QueryClient } from '@tanstack/vue-query' +import { flushPromises } from '@vue/test-utils' + +import type { ApiClient, ApiPath, ApiRequestOptions } from '@/api/client' +import type { RequestLogItemDto, RequestLogPageDto } from '@/api/control/request-logs' +import type { AccessKeyDto, GroupSummary } from '@/api/control/types' +import { controlQueryKeys } from '@/app/query-keys' +import { mountApp } from '@/test/mount-app' + +import LogsTab from './LogsTab.vue' + +const firstRequestID = 'a4d4e121-8ac3-4df4-8ceb-63b10ddc6173' + +afterEach(() => vi.unstubAllEnvs()) + +function logFixture(overrides: Partial = {}): RequestLogItemDto { + return { + request_id: firstRequestID, + completed_at: '2026-07-25T10:00:01Z', + access_key: { id: 12, name: 'client', deleted: false }, + protocol: 'openai', + client_model: 'gpt-client', + upstream_model: 'gpt-upstream', + status: 'success', + status_code: 200, + duration_ms: 125, + error_code: '', + error_summary: '', + affinity_hit: false, + attempts: [], + ...overrides, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +class LogsApi implements ApiClient { + readonly requests: Array<{ path: ApiPath; options?: ApiRequestOptions }> = [] + private logRequestCount = 0 + + constructor( + private readonly pages: Array> = [ + { + items: [logFixture()], + next_cursor: null, + }, + ], + private readonly options: { + groups?: GroupSummary[] | Promise + accessKeys?: AccessKeyDto[] | Promise + groupsError?: Error + accessKeysError?: Error + } = {}, + ) {} + + request(path: ApiPath, options?: ApiRequestOptions): Promise { + this.requests.push({ path, options }) + if (path.startsWith('/api/logs')) { + const page = this.pages[this.logRequestCount] ?? this.pages.at(-1) + this.logRequestCount += 1 + return Promise.resolve(page as T) + } + if (path === '/api/groups') { + if (this.options.groupsError) return Promise.reject(this.options.groupsError) + return Promise.resolve((this.options.groups ?? []) as T) + } + if (path === '/api/access-keys') { + if (this.options.accessKeysError) return Promise.reject(this.options.accessKeysError) + return Promise.resolve((this.options.accessKeys ?? []) as T) + } + throw new Error(`Unexpected request: ${path}`) + } +} + +function createQueryClient(): QueryClient { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +async function mountLogs( + api: ApiClient, + path = '/monitor?tab=logs', + locale: 'zh-CN' | 'en-US' | 'ja-JP' = 'zh-CN', +) { + const queryClient = createQueryClient() + const mounted = await mountApp(LogsTab, { + api, + queryClient, + path, + locale, + mounting: { attachTo: document.body }, + }) + await flushPromises() + return { ...mounted, queryClient } +} + +describe('LogsTab', () => { + it('loads the first page without implicit time, limit, page, or cursor parameters', async () => { + const api = new LogsApi() + + const { wrapper } = await mountLogs(api) + + expect(api.requests.filter(({ path }) => path.startsWith('/api/logs'))).toEqual([ + { + path: '/api/logs', + options: { method: 'GET', signal: expect.any(AbortSignal) }, + }, + ]) + expect(wrapper.get(`[data-test="log-row-${firstRequestID}"]`).text()).toContain('gpt-client') + }) + + it('passes the opaque forward cursor unchanged, appends once, and hides Load more at the end', async () => { + const opaqueCursor = 'opaque:server/+token==' + const secondRequestID = 'b4d4e121-8ac3-4df4-8ceb-63b10ddc6173' + const api = new LogsApi([ + { + items: [logFixture()], + next_cursor: opaqueCursor, + }, + { + items: [logFixture({ request_id: secondRequestID, client_model: 'second-model' })], + next_cursor: null, + }, + ]) + + const { wrapper } = await mountLogs(api) + await wrapper.get('[data-test="logs-load-more"]').trigger('click') + await flushPromises() + + const logRequests = api.requests.filter(({ path }) => path.startsWith('/api/logs')) + expect(logRequests).toHaveLength(2) + expect( + new URL(logRequests[1]!.path, 'https://gpt-load.invalid').searchParams.get('cursor'), + ).toBe(opaqueCursor) + expect(wrapper.findAll('tbody tr')).toHaveLength(2) + expect(wrapper.text()).toContain('second-model') + expect(wrapper.find('[data-test="logs-load-more"]').exists()).toBe(false) + }) + + it('applies filters through the URL and clears prior rows while the new first page loads', async () => { + const filtered = deferred() + const filteredRequestID = 'c4d4e121-8ac3-4df4-8ceb-63b10ddc6173' + const api = new LogsApi([{ items: [logFixture()], next_cursor: null }, filtered.promise]) + const { router, wrapper } = await mountLogs(api) + + await wrapper.get('[data-test="logs-status"]').setValue('error') + await wrapper.get('[data-test="logs-filter-form"]').trigger('submit') + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/monitor?tab=logs&status=error') + expect(wrapper.find(`[data-test="log-row-${firstRequestID}"]`).exists()).toBe(false) + + filtered.resolve({ + items: [ + logFixture({ + request_id: filteredRequestID, + client_model: 'filtered-model', + status: 'error', + }), + ], + next_cursor: null, + }) + await flushPromises() + + expect(wrapper.find(`[data-test="log-row-${firstRequestID}"]`).exists()).toBe(false) + expect(wrapper.get(`[data-test="log-row-${filteredRequestID}"]`).text()).toContain( + 'filtered-model', + ) + }) + + it('cancels an old-filter page request and ignores its late response after Apply', async () => { + const oldNext = deferred() + const filtered = deferred() + const lateRequestID = 'd4d4e121-8ac3-4df4-8ceb-63b10ddc6173' + const filteredRequestID = 'e4d4e121-8ac3-4df4-8ceb-63b10ddc6173' + const api = new LogsApi([ + { items: [logFixture()], next_cursor: 'old-next' }, + oldNext.promise, + filtered.promise, + ]) + const { wrapper } = await mountLogs(api) + + await wrapper.get('[data-test="logs-load-more"]').trigger('click') + await flushPromises() + const oldPageRequest = api.requests.filter(({ path }) => path.startsWith('/api/logs'))[1] + expect(oldPageRequest?.options?.signal?.aborted).toBe(false) + + await wrapper.get('[data-test="logs-status"]').setValue('error') + await wrapper.get('[data-test="logs-filter-form"]').trigger('submit') + await flushPromises() + + expect(oldPageRequest?.options?.signal?.aborted).toBe(true) + expect(wrapper.find(`[data-test="log-row-${firstRequestID}"]`).exists()).toBe(false) + + oldNext.resolve({ + items: [logFixture({ request_id: lateRequestID, client_model: 'late-old-model' })], + next_cursor: null, + }) + await flushPromises() + expect(wrapper.find(`[data-test="log-row-${lateRequestID}"]`).exists()).toBe(false) + + filtered.resolve({ + items: [ + logFixture({ + request_id: filteredRequestID, + client_model: 'current-model', + status: 'error', + }), + ], + next_cursor: null, + }) + await flushPromises() + + expect(wrapper.find(`[data-test="log-row-${lateRequestID}"]`).exists()).toBe(false) + expect(wrapper.get(`[data-test="log-row-${filteredRequestID}"]`).text()).toContain( + 'current-model', + ) + }) + + it('refreshes the first page atomically and ignores a canceled late next page', async () => { + const inFlightNext = deferred() + const refreshed = deferred() + const secondRequestID = 'f4d4e121-8ac3-4df4-8ceb-63b10ddc6173' + const lateRequestID = '14d4e121-8ac3-4df4-8ceb-63b10ddc6173' + const refreshedRequestID = '24d4e121-8ac3-4df4-8ceb-63b10ddc6173' + const refreshedPage: RequestLogPageDto = { + items: [ + logFixture({ + request_id: refreshedRequestID, + client_model: 'refreshed-model', + status: 'error', + }), + ], + next_cursor: null, + } + const api = new LogsApi([ + { items: [logFixture({ status: 'error' })], next_cursor: 'page-2' }, + { + items: [ + logFixture({ + request_id: secondRequestID, + client_model: 'second-page-model', + status: 'error', + }), + ], + next_cursor: 'page-3', + }, + inFlightNext.promise, + refreshed.promise, + ]) + const { queryClient, wrapper } = await mountLogs(api, '/monitor?tab=logs&status=error') + + await wrapper.get('[data-test="logs-load-more"]').trigger('click') + await flushPromises() + await wrapper.get('[data-test="logs-load-more"]').trigger('click') + await flushPromises() + const inFlightRequest = api.requests.filter(({ path }) => path.startsWith('/api/logs'))[2] + expect(inFlightRequest?.options?.signal?.aborted).toBe(false) + + await wrapper.get('[data-test="logs-refresh"]').trigger('click') + await flushPromises() + + expect(inFlightRequest?.options?.signal?.aborted).toBe(true) + expect(wrapper.findAll('tbody tr')).toHaveLength(2) + const refreshRequest = api.requests.filter(({ path }) => path.startsWith('/api/logs'))[3] + expect(refreshRequest?.path).toBe('/api/logs?status=error') + + refreshed.resolve(refreshedPage) + await flushPromises() + + expect(queryClient.getQueryData(controlQueryKeys.logs.list({ status: 'error' }))).toEqual({ + pages: [refreshedPage], + pageParams: [null], + }) + expect(wrapper.findAll('tbody tr')).toHaveLength(1) + expect(wrapper.text()).toContain('refreshed-model') + + inFlightNext.resolve({ + items: [logFixture({ request_id: lateRequestID, client_model: 'late-page-model' })], + next_cursor: null, + }) + await flushPromises() + expect(wrapper.find(`[data-test="log-row-${lateRequestID}"]`).exists()).toBe(false) + expect(wrapper.findAll('tbody tr')).toHaveLength(1) + }) + + it('preserves every cached page when Refresh fails and offers a local retry', async () => { + const refresh = deferred() + const retry = deferred() + const secondRequestID = '34d4e121-8ac3-4df4-8ceb-63b10ddc6173' + const retriedRequestID = '44d4e121-8ac3-4df4-8ceb-63b10ddc6173' + const firstPage: RequestLogPageDto = { + items: [logFixture({ status: 'error' })], + next_cursor: 'page-2', + } + const secondPage: RequestLogPageDto = { + items: [ + logFixture({ + request_id: secondRequestID, + client_model: 'second-page-model', + status: 'error', + }), + ], + next_cursor: null, + } + const api = new LogsApi([firstPage, secondPage, refresh.promise, retry.promise]) + const { queryClient, wrapper } = await mountLogs(api, '/monitor?tab=logs&status=error') + await wrapper.get('[data-test="logs-load-more"]').trigger('click') + await flushPromises() + + await wrapper.get('[data-test="logs-refresh"]').trigger('click') + await flushPromises() + refresh.reject(new Error('refresh-secret-canary')) + await flushPromises() + + expect(queryClient.getQueryData(controlQueryKeys.logs.list({ status: 'error' }))).toEqual({ + pages: [firstPage, secondPage], + pageParams: [null, 'page-2'], + }) + expect(wrapper.findAll('tbody tr')).toHaveLength(2) + expect(wrapper.get('[data-test="logs-refresh-failed"]').text()).toContain('刷新请求日志失败') + expect(wrapper.text()).not.toContain('refresh-secret-canary') + + await wrapper.get('[data-test="logs-refresh-retry"]').trigger('click') + await flushPromises() + retry.resolve({ + items: [ + logFixture({ + request_id: retriedRequestID, + client_model: 'retried-model', + status: 'error', + }), + ], + next_cursor: null, + }) + await flushPromises() + + expect(wrapper.findAll('tbody tr')).toHaveLength(1) + expect(wrapper.text()).toContain('retried-model') + expect(wrapper.find('[data-test="logs-refresh-failed"]').exists()).toBe(false) + }) + + it('distinguishes a system with no logs from a filter with no matches', async () => { + const unfiltered = await mountLogs(new LogsApi([{ items: [], next_cursor: null }])) + + expect(unfiltered.wrapper.get('[data-test="logs-empty-unfiltered"]').text()).toContain( + '尚无请求日志', + ) + unfiltered.wrapper.unmount() + + const filtered = await mountLogs( + new LogsApi([{ items: [], next_cursor: null }]), + '/monitor?tab=logs&model=no-match', + ) + + expect(filtered.wrapper.get('[data-test="logs-empty-filtered"]').text()).toContain( + '没有匹配当前筛选条件', + ) + }) + + it('keeps option failures local and caches only safe AccessKey options with zero gc time', async () => { + const secret = 'sk-gl-options-secret-canary' + const groupError = 'group-options-secret-canary' + const api = new LogsApi([{ items: [logFixture()], next_cursor: null }], { + groupsError: new Error(groupError), + accessKeys: [ + { + id: 12, + name: 'client', + key: secret, + status: 'active', + filters: { groups: [], protocols: [], models: [] }, + rpm_limit: 0, + }, + ], + }) + + const { queryClient, wrapper } = await mountLogs(api) + + expect(wrapper.find(`[data-test="log-row-${firstRequestID}"]`).exists()).toBe(true) + expect(wrapper.get('[data-test="logs-group-options-failed"]').text()).toContain( + '无法加载 Group 筛选选项', + ) + expect(wrapper.text()).not.toContain(groupError) + expect(wrapper.text()).not.toContain(secret) + expect(queryClient.getQueryData(controlQueryKeys.accessKeys.options())).toEqual([ + { id: 12, name: 'client', status: 'active' }, + ]) + expect(JSON.stringify(queryClient.getQueryCache().getAll())).not.toContain(secret) + + wrapper.unmount() + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(queryClient.getQueryData(controlQueryKeys.accessKeys.options())).toBeUndefined() + }) + + it('applies every explicit filter exactly and Reset clears both draft and URL', async () => { + vi.stubEnv('TZ', 'UTC') + const group: GroupSummary = { + id: 7, + name: 'Primary', + upstream_url: 'https://api.example.com', + protocols: ['openai'], + models: [], + enabled: true, + key_count: 1, + } + const accessKey: AccessKeyDto = { + id: 12, + name: 'client', + key: 'sk-gl-filter-secret-canary', + status: 'active', + filters: { groups: [], protocols: [], models: [] }, + rpm_limit: 0, + } + const api = new LogsApi( + [ + { items: [logFixture()], next_cursor: null }, + { items: [logFixture({ status: 'error' })], next_cursor: null }, + { items: [logFixture()], next_cursor: null }, + ], + { groups: [group], accessKeys: [accessKey] }, + ) + const { router, wrapper } = await mountLogs(api) + + await wrapper.get('[data-test="logs-from"]').setValue('2026-07-25T10:00') + await wrapper.get('[data-test="logs-to"]').setValue('2026-07-25T11:00') + await wrapper.get('[data-test="logs-group"]').setValue('7') + await wrapper.get('[data-test="logs-model"]').setValue('provider/model:Exact') + await wrapper.get('[data-test="logs-access-key"]').setValue('12') + await wrapper.get('[data-test="logs-status"]').setValue('error') + await wrapper + .get('[data-test="logs-request-id"]') + .setValue('a4d4e121-8ac3-4df4-8ceb-63b10ddc6173') + await wrapper.get('[data-test="logs-filter-form"]').trigger('submit') + await flushPromises() + + expect(router.currentRoute.value.query).toEqual({ + tab: 'logs', + from: '2026-07-25T10:00:00.000Z', + to: '2026-07-25T11:00:00.000Z', + group_id: '7', + model: 'provider/model:Exact', + access_key_id: '12', + status: 'error', + request_id: 'a4d4e121-8ac3-4df4-8ceb-63b10ddc6173', + }) + expect(wrapper.text()).toContain('匹配任一尝试') + expect(wrapper.text()).not.toContain(accessKey.key) + + await wrapper.get('[data-test="logs-reset"]').trigger('click') + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/monitor?tab=logs') + for (const testID of [ + 'logs-from', + 'logs-to', + 'logs-group', + 'logs-model', + 'logs-access-key', + 'logs-status', + 'logs-request-id', + ]) { + expect((wrapper.get(`[data-test="${testID}"]`).element as HTMLInputElement).value).toBe('') + } + }) + + it('rejects an invalid draft locally without echoing raw values or requesting logs again', async () => { + const rawInvalid = 'NOT-A-UUID-secret-canary' + const api = new LogsApi() + const { router, wrapper } = await mountLogs(api) + + await wrapper.get('[data-test="logs-from"]').setValue('2026-07-25T10:00') + await wrapper.get('[data-test="logs-to"]').setValue('2026-07-25T10:00') + await wrapper.get('[data-test="logs-request-id"]').setValue(rawInvalid) + await wrapper.get('[data-test="logs-filter-form"]').trigger('submit') + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/monitor?tab=logs') + expect(api.requests.filter(({ path }) => path.startsWith('/api/logs'))).toHaveLength(1) + expect(wrapper.text()).toContain('结束时间必须晚于开始时间') + expect(wrapper.text()).toContain('请输入规范的小写 UUIDv4') + expect(wrapper.text()).not.toContain(rawInvalid) + }) + + it('does not append the same request twice when a next-page response is duplicated', async () => { + const secondRequestID = '54d4e121-8ac3-4df4-8ceb-63b10ddc6173' + const api = new LogsApi([ + { items: [logFixture()], next_cursor: 'duplicate-next' }, + { + items: [ + logFixture(), + logFixture({ request_id: secondRequestID, client_model: 'unique-next-model' }), + ], + next_cursor: null, + }, + ]) + const { wrapper } = await mountLogs(api) + + await wrapper.get('[data-test="logs-load-more"]').trigger('click') + await flushPromises() + + expect(wrapper.findAll(`[data-test="log-row-${firstRequestID}"]`)).toHaveLength(1) + expect(wrapper.findAll('tbody tr')).toHaveLength(2) + expect(wrapper.text()).toContain('unique-next-model') + }) + + it('labels a deleted historical AccessKey by numeric ID', async () => { + const api = new LogsApi([ + { + items: [ + logFixture({ + access_key: { id: 99, name: null, deleted: true }, + }), + ], + next_cursor: null, + }, + ]) + + const { wrapper } = await mountLogs(api, '/monitor?tab=logs', 'en-US') + + expect(wrapper.get(`[data-test="log-row-${firstRequestID}"]`).text()).toContain('Deleted · #99') + }) + + it('opens a real detail drawer and restores focus to the row trigger on close', async () => { + const { wrapper } = await mountLogs(new LogsApi(), '/monitor?tab=logs', 'en-US') + const trigger = wrapper.get(`[data-test="log-details-${firstRequestID}"]`) + trigger.element.focus() + + await trigger.trigger('click') + await flushPromises() + + expect(document.body.textContent).toContain('Request log details') + const close = document.body.querySelector( + 'button[aria-label="Close request log details"]', + ) + if (!close) throw new Error('Missing drawer close button') + close.click() + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(document.body.querySelector('button[aria-label="Close request log details"]')).toBeNull() + expect(document.activeElement).toBe( + wrapper.get(`[data-test="log-details-${firstRequestID}"]`).element, + ) + }) +}) diff --git a/web/src/features/monitor/LogsTab.vue b/web/src/features/monitor/LogsTab.vue new file mode 100644 index 00000000..95dfca0b --- /dev/null +++ b/web/src/features/monitor/LogsTab.vue @@ -0,0 +1,545 @@ + + + + + diff --git a/web/src/features/monitor/MonitorView.test.ts b/web/src/features/monitor/MonitorView.test.ts index 02aa25b8..5e3368f3 100644 --- a/web/src/features/monitor/MonitorView.test.ts +++ b/web/src/features/monitor/MonitorView.test.ts @@ -17,6 +17,9 @@ async function mountMonitor(path: string) { HealthTab: { template: '
', }, + LogsTab: { + template: '
', + }, }, }, }) @@ -31,6 +34,7 @@ describe('MonitorView', () => { expect(wrapper.get('[data-test="monitor-tab-logs"]').attributes()['aria-selected']).toBe('true') expect(wrapper.find('[data-test="monitor-health-slot"]').exists()).toBe(false) expect(wrapper.find('[data-test="monitor-logs-slot"]').exists()).toBe(true) + expect(wrapper.find('[data-test="monitor-logs-content"]').exists()).toBe(true) expect(wrapper.find('[data-test="monitor-inspector-slot"]').exists()).toBe(false) await wrapper.get('[data-test="monitor-tab-inspector"]').trigger('click') diff --git a/web/src/features/monitor/MonitorView.vue b/web/src/features/monitor/MonitorView.vue index b3f1c0f9..811a7dba 100644 --- a/web/src/features/monitor/MonitorView.vue +++ b/web/src/features/monitor/MonitorView.vue @@ -7,6 +7,7 @@ import AppTabs, { type AppTabItem } from '@/components/ui/AppTabs.vue' import PageHeader from '@/components/ui/PageHeader.vue' import HealthTab from './HealthTab.vue' +import LogsTab from './LogsTab.vue' import { normalizeMonitorQuery, normalizeMonitorTab, sameMonitorQuery } from './monitor-route' const route = useRoute() @@ -53,7 +54,9 @@ function selectTab(value: string): void {
-
+
+ +
diff --git a/web/src/features/monitor/log-filters.test.ts b/web/src/features/monitor/log-filters.test.ts new file mode 100644 index 00000000..c202e640 --- /dev/null +++ b/web/src/features/monitor/log-filters.test.ts @@ -0,0 +1,145 @@ +import { + applyLogFilterDraft, + createLogFilterDraft, + parseAppliedLogFilters, + serializeAppliedLogFilters, + validateLogFilterDraft, + type LogFilterDraft, +} from './log-filters' + +afterEach(() => vi.unstubAllEnvs()) + +describe('log filters', () => { + it('converts optional local datetimes to RFC3339 and back without adding absent bounds', () => { + vi.stubEnv('TZ', 'UTC') + const draft: LogFilterDraft = { + from: '2026-07-25T10:00', + to: '', + group_id: '', + model: '', + access_key_id: '', + status: '', + request_id: '', + } + + expect(applyLogFilterDraft(draft)).toEqual({ + from: '2026-07-25T10:00:00.000Z', + }) + expect(createLogFilterDraft({ from: '2026-07-25T10:00:00.000Z' })).toEqual(draft) + }) + + it('parses normalized query values and serializes exact applied filters without pagination', () => { + const filters = parseAppliedLogFilters({ + tab: 'logs', + from: '2026-07-25T10:00:00.000Z', + group_id: '7', + model: 'provider/model:exact', + access_key_id: '12', + status: 'error', + request_id: 'a4d4e121-8ac3-4df4-8ceb-63b10ddc6173', + }) + + expect(filters).toEqual({ + from: '2026-07-25T10:00:00.000Z', + group_id: 7, + model: 'provider/model:exact', + access_key_id: 12, + status: 'error', + request_id: 'a4d4e121-8ac3-4df4-8ceb-63b10ddc6173', + }) + expect(serializeAppliedLogFilters(filters)).toEqual({ + tab: 'logs', + from: '2026-07-25T10:00:00.000Z', + group_id: '7', + model: 'provider/model:exact', + access_key_id: '12', + status: 'error', + request_id: 'a4d4e121-8ac3-4df4-8ceb-63b10ddc6173', + }) + }) + + it.each(['success', 'error', 'incomplete', 'canceled'] as const)( + 'preserves exact model and the %s status while blanks reset every optional filter', + (status) => { + const filters = applyLogFilterDraft({ + from: '', + to: '', + group_id: '7', + model: 'provider/model:Exact', + access_key_id: '12', + status, + request_id: 'a4d4e121-8ac3-4df4-8ceb-63b10ddc6173', + }) + + expect(filters).toEqual({ + group_id: 7, + model: 'provider/model:Exact', + access_key_id: 12, + status, + request_id: 'a4d4e121-8ac3-4df4-8ceb-63b10ddc6173', + }) + expect(createLogFilterDraft(filters)).toEqual({ + from: '', + to: '', + group_id: '7', + model: 'provider/model:Exact', + access_key_id: '12', + status, + request_id: 'a4d4e121-8ac3-4df4-8ceb-63b10ddc6173', + }) + expect(applyLogFilterDraft(createLogFilterDraft())).toEqual({}) + }, + ) + + it('rejects non-positive IDs, non-canonical UUIDv4, and unknown status using safe locale keys', () => { + const rawSecret = 'NOT-A-UUID-secret-canary' + const errors = validateLogFilterDraft({ + from: '', + to: '', + group_id: '0', + model: '', + access_key_id: '1.5', + status: 'failed', + request_id: rawSecret, + }) + + expect(errors).toEqual({ + group_id: 'monitor.logs.errors.positiveId', + access_key_id: 'monitor.logs.errors.positiveId', + status: 'monitor.logs.errors.status', + request_id: 'monitor.logs.errors.requestId', + }) + expect(JSON.stringify(errors)).not.toContain(rawSecret) + expect( + validateLogFilterDraft({ + ...createLogFilterDraft(), + request_id: 'A4D4E121-8AC3-4DF4-8CEB-63B10DDC6173', + }), + ).toEqual({ request_id: 'monitor.logs.errors.requestId' }) + }) + + it('requires valid local datetimes and a strictly increasing optional time range', () => { + const base = createLogFilterDraft() + + expect( + validateLogFilterDraft({ + ...base, + from: '2026-02-30T10:00', + }), + ).toEqual({ from: 'monitor.logs.errors.dateTime' }) + expect( + validateLogFilterDraft({ + ...base, + from: '2026-07-25T10:00', + to: '2026-07-25T10:00', + }), + ).toEqual({ to: 'monitor.logs.errors.range' }) + expect( + validateLogFilterDraft({ + ...base, + from: '2026-07-25T10:00', + to: '2026-07-25T10:01', + }), + ).toEqual({}) + }) +}) diff --git a/web/src/features/monitor/log-filters.ts b/web/src/features/monitor/log-filters.ts new file mode 100644 index 00000000..a918abaf --- /dev/null +++ b/web/src/features/monitor/log-filters.ts @@ -0,0 +1,168 @@ +import type { LocationQueryRaw } from 'vue-router' + +import type { RequestLogFilters, RequestLogStatus } from '@/api/control/request-logs' + +import { requestLogStatuses } from './monitor-route' + +export interface LogFilterDraft { + from: string + to: string + group_id: string + model: string + access_key_id: string + status: string + request_id: string +} + +export type LogFilterErrors = Partial> + +const requestIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +const emptyDraft = (): LogFilterDraft => ({ + from: '', + to: '', + group_id: '', + model: '', + access_key_id: '', + status: '', + request_id: '', +}) + +function toLocalDateTime(value: string | undefined): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + const pad = (part: number) => String(part).padStart(2, '0') + return [ + date.getFullYear(), + '-', + pad(date.getMonth() + 1), + '-', + pad(date.getDate()), + 'T', + pad(date.getHours()), + ':', + pad(date.getMinutes()), + ].join('') +} + +export function createLogFilterDraft(filters: RequestLogFilters = {}): LogFilterDraft { + return { + ...emptyDraft(), + from: toLocalDateTime(filters.from), + to: toLocalDateTime(filters.to), + group_id: filters.group_id === undefined ? '' : String(filters.group_id), + model: filters.model ?? '', + access_key_id: filters.access_key_id === undefined ? '' : String(filters.access_key_id), + status: filters.status ?? '', + request_id: filters.request_id ?? '', + } +} + +export function applyLogFilterDraft(draft: LogFilterDraft): RequestLogFilters { + const filters: RequestLogFilters = {} + if (draft.from) filters.from = new Date(draft.from).toISOString() + if (draft.to) filters.to = new Date(draft.to).toISOString() + if (draft.group_id) filters.group_id = Number(draft.group_id) + if (draft.model) filters.model = draft.model + if (draft.access_key_id) filters.access_key_id = Number(draft.access_key_id) + if (draft.status) filters.status = draft.status as RequestLogStatus + if (draft.request_id) filters.request_id = draft.request_id + return filters +} + +export function parseAppliedLogFilters(query: Record): RequestLogFilters { + const filters: RequestLogFilters = {} + if (typeof query.from === 'string') filters.from = query.from + if (typeof query.to === 'string') filters.to = query.to + if (typeof query.group_id === 'string') filters.group_id = Number(query.group_id) + if (typeof query.model === 'string') filters.model = query.model + if (typeof query.access_key_id === 'string') filters.access_key_id = Number(query.access_key_id) + if (typeof query.status === 'string') filters.status = query.status as RequestLogStatus + if (typeof query.request_id === 'string') filters.request_id = query.request_id + return filters +} + +export function serializeAppliedLogFilters(filters: RequestLogFilters): LocationQueryRaw { + const query: LocationQueryRaw = { tab: 'logs' } + if (filters.from !== undefined) query.from = filters.from + if (filters.to !== undefined) query.to = filters.to + if (filters.group_id !== undefined) query.group_id = String(filters.group_id) + if (filters.model !== undefined) query.model = filters.model + if (filters.access_key_id !== undefined) query.access_key_id = String(filters.access_key_id) + if (filters.status !== undefined) query.status = filters.status + if (filters.request_id !== undefined) query.request_id = filters.request_id + return query +} + +function isPositiveID(value: string): boolean { + if (!/^\d+$/.test(value)) return false + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 +} + +function parseLocalDateTime(value: string): Date | undefined { + const match = value.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/, + ) + if (!match) return undefined + const [, year, month, day, hour, minute, second = '0', milliseconds = '0'] = match + const parts = [year, month, day, hour, minute, second, milliseconds.padEnd(3, '0')].map(Number) + const [yearValue, monthValue, dayValue, hourValue, minuteValue, secondValue, msValue] = parts + if ( + yearValue === undefined || + monthValue === undefined || + dayValue === undefined || + hourValue === undefined || + minuteValue === undefined || + secondValue === undefined || + msValue === undefined + ) { + return undefined + } + const date = new Date( + yearValue, + monthValue - 1, + dayValue, + hourValue, + minuteValue, + secondValue, + msValue, + ) + if ( + date.getFullYear() !== yearValue || + date.getMonth() !== monthValue - 1 || + date.getDate() !== dayValue || + date.getHours() !== hourValue || + date.getMinutes() !== minuteValue || + date.getSeconds() !== secondValue || + date.getMilliseconds() !== msValue + ) { + return undefined + } + return date +} + +export function validateLogFilterDraft(draft: LogFilterDraft): LogFilterErrors { + const errors: LogFilterErrors = {} + const from = draft.from ? parseLocalDateTime(draft.from) : undefined + const to = draft.to ? parseLocalDateTime(draft.to) : undefined + if (draft.from && !from) errors.from = 'monitor.logs.errors.dateTime' + if (draft.to && !to) errors.to = 'monitor.logs.errors.dateTime' + if (from && to && from.getTime() >= to.getTime()) { + errors.to = 'monitor.logs.errors.range' + } + if (draft.group_id && !isPositiveID(draft.group_id)) { + errors.group_id = 'monitor.logs.errors.positiveId' + } + if (draft.access_key_id && !isPositiveID(draft.access_key_id)) { + errors.access_key_id = 'monitor.logs.errors.positiveId' + } + if (draft.status && !requestLogStatuses.includes(draft.status as RequestLogStatus)) { + errors.status = 'monitor.logs.errors.status' + } + if (draft.request_id && !requestIDPattern.test(draft.request_id)) { + errors.request_id = 'monitor.logs.errors.requestId' + } + return errors +} diff --git a/web/src/features/monitor/monitor-route.ts b/web/src/features/monitor/monitor-route.ts index 199a9720..49904f6a 100644 --- a/web/src/features/monitor/monitor-route.ts +++ b/web/src/features/monitor/monitor-route.ts @@ -5,7 +5,12 @@ import type { RequestLogStatus } from '@/api/control/request-logs' export type MonitorTab = 'health' | 'logs' | 'inspector' -const requestLogStatuses: RequestLogStatus[] = ['success', 'error', 'incomplete', 'canceled'] +export const requestLogStatuses = [ + 'success', + 'error', + 'incomplete', + 'canceled', +] as const satisfies readonly RequestLogStatus[] const accessProtocols: AccessProtocol[] = ['openai', 'anthropic', 'gemini', 'openai-response'] const requestIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ const rfc3339Pattern = diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 83690d65..9e8fb7c3 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -282,6 +282,119 @@ export default { lastRetentionFailureAt: 'Last retention failure', }, }, + logs: { + loading: 'Loading request logs…', + loadFailed: 'Unable to load request logs.', + loadMore: 'Load more', + refresh: 'Refresh', + refreshFailed: 'Unable to refresh request logs. Every current page was preserved.', + caption: 'Request logs', + empty: { + title: 'No request logs yet', + description: 'Redacted logs will appear here after client requests arrive.', + filteredTitle: 'No logs match the current filters', + filteredDescription: 'Adjust or reset the filters and try again.', + }, + options: { + groupsFailed: 'Unable to load Group filter options. The log list is unaffected.', + accessKeysFailed: 'Unable to load AccessKey filter options. The log list is unaffected.', + }, + filters: { + label: 'Request log filters', + from: 'From', + to: 'To', + group: 'Group', + groupHelp: 'Matches any Group used by an attempt.', + anyGroup: 'Any Group', + model: 'Client model', + accessKey: 'AccessKey', + anyAccessKey: 'Any AccessKey', + status: 'Status', + anyStatus: 'Any status', + requestId: 'Request ID', + apply: 'Apply', + reset: 'Reset', + }, + errors: { + dateTime: 'Enter a valid local date and time.', + range: 'To must be later than From.', + positiveId: 'Select a valid positive ID.', + status: 'Select a valid status.', + requestId: 'Enter a canonical lowercase UUIDv4.', + }, + accessKey: { + named: '{name} · #{id}', + deleted: 'Deleted · #{id}', + }, + none: 'None', + yes: 'Yes', + no: 'No', + details: 'View details', + failureCategory: { + ok: 'OK', + rate_limited: 'Rate limited', + model_unavailable: 'Model unavailable', + invalid_key: 'Invalid key', + upstream_host_error: 'Upstream host error', + client_error: 'Client error', + downstream_cancel: 'Downstream canceled', + ambiguous: 'Ambiguous', + unknown: 'Unknown category', + }, + action: { + terminate: 'Terminate', + retry: 'Retry', + cooldown_key: 'Cooldown key', + fail_key: 'Fail key', + skip_group: 'Skip Group', + unknown: 'Unknown action', + }, + drawer: { + title: 'Request log details', + description: 'Review the final result and redacted upstream attempt chain.', + close: 'Close request log details', + summary: 'Final result', + requestId: 'Request ID', + copyRequestId: 'Copy request ID', + completedAt: 'Completed', + status: 'Status', + protocol: 'Protocol', + accessKey: 'AccessKey', + clientModel: 'Client model', + upstreamModel: 'Upstream model', + statusCode: 'Status code', + duration: 'Duration', + affinity: 'Affinity hit', + errorCode: 'Error code', + errorSummary: 'Redacted error summary', + openInspector: 'Inspect in Route Inspector', + attempts: 'Attempt chain', + noAttempts: 'This request made no upstream attempts.', + attempt: 'Attempt #{sequence}', + committed: 'Committed', + notCommitted: 'Not committed', + group: 'Historical Group', + upstreamKey: 'Historical upstream key', + failureCategory: 'Failure category', + action: 'Action', + willRetry: 'Will retry', + }, + columns: { + completedAt: 'Completed', + requestId: 'Request ID', + model: 'Client model', + accessKey: 'AccessKey', + status: 'Status', + duration: 'Duration', + actions: 'Actions', + }, + status: { + success: 'Success', + error: 'Error', + incomplete: 'Incomplete', + canceled: 'Canceled', + }, + }, }, group: { invalidTitle: 'Invalid Group address', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index bf6b6e39..30ce3b2e 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -281,6 +281,120 @@ export default { lastRetentionFailureAt: '直近の保持処理失敗', }, }, + logs: { + loading: 'リクエストログを読み込み中…', + loadFailed: 'リクエストログを読み込めません。', + loadMore: 'さらに読み込む', + refresh: '更新', + refreshFailed: 'リクエストログを更新できません。現在の全ページは保持されています。', + caption: 'リクエストログ', + empty: { + title: 'リクエストログはまだありません', + description: 'クライアントリクエストを受信すると、秘匿化されたログが表示されます。', + filteredTitle: '現在のフィルターに一致するログはありません', + filteredDescription: 'フィルターを調整またはリセットして、再試行してください。', + }, + options: { + groupsFailed: 'Group フィルターの選択肢を読み込めません。ログ一覧には影響しません。', + accessKeysFailed: + 'AccessKey フィルターの選択肢を読み込めません。ログ一覧には影響しません。', + }, + filters: { + label: 'リクエストログのフィルター', + from: '開始時刻', + to: '終了時刻', + group: 'Group', + groupHelp: 'いずれかの試行で使用された Group に一致します。', + anyGroup: 'すべての Group', + model: 'クライアントモデル', + accessKey: 'AccessKey', + anyAccessKey: 'すべての AccessKey', + status: '状態', + anyStatus: 'すべての状態', + requestId: 'リクエスト ID', + apply: '適用', + reset: 'リセット', + }, + errors: { + dateTime: '有効なローカル日時を入力してください。', + range: '終了時刻は開始時刻より後である必要があります。', + positiveId: '有効な正の ID を選択してください。', + status: '有効な状態を選択してください。', + requestId: '正規の小文字 UUIDv4 を入力してください。', + }, + accessKey: { + named: '{name} · #{id}', + deleted: '削除済み · #{id}', + }, + none: 'なし', + yes: 'はい', + no: 'いいえ', + details: '詳細を表示', + failureCategory: { + ok: '正常', + rate_limited: 'レート制限', + model_unavailable: 'モデル利用不可', + invalid_key: '無効なキー', + upstream_host_error: 'アップストリームホストエラー', + client_error: 'クライアントエラー', + downstream_cancel: 'ダウンストリームによるキャンセル', + ambiguous: '原因不明', + unknown: '不明な分類', + }, + action: { + terminate: '終了', + retry: '再試行', + cooldown_key: 'Key をクールダウン', + fail_key: 'Key を失敗扱い', + skip_group: 'Group をスキップ', + unknown: '不明なアクション', + }, + drawer: { + title: 'リクエストログ詳細', + description: '最終結果と秘匿化されたアップストリーム試行チェーンを確認します。', + close: 'リクエストログ詳細を閉じる', + summary: '最終結果', + requestId: 'リクエスト ID', + copyRequestId: 'リクエスト ID をコピー', + completedAt: '完了時刻', + status: '状態', + protocol: 'プロトコル', + accessKey: 'AccessKey', + clientModel: 'クライアントモデル', + upstreamModel: 'アップストリームモデル', + statusCode: 'ステータスコード', + duration: '所要時間', + affinity: 'アフィニティヒット', + errorCode: 'エラーコード', + errorSummary: '秘匿化されたエラー概要', + openInspector: 'Route Inspector で確認', + attempts: '試行チェーン', + noAttempts: 'このリクエストにはアップストリーム試行がありません。', + attempt: '試行 #{sequence}', + committed: '確定済み', + notCommitted: '未確定', + group: '履歴 Group', + upstreamKey: '履歴アップストリーム Key', + failureCategory: '失敗分類', + action: 'アクション', + willRetry: '再試行予定', + }, + columns: { + completedAt: '完了時刻', + requestId: 'リクエスト ID', + model: 'クライアントモデル', + accessKey: 'AccessKey', + status: '状態', + duration: '所要時間', + actions: '操作', + }, + status: { + success: '成功', + error: 'エラー', + incomplete: '未完了', + canceled: 'キャンセル済み', + }, + }, }, group: { invalidTitle: 'Group アドレスが無効です', diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index b8746e6c..9f68b676 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -274,6 +274,119 @@ export default { lastRetentionFailureAt: '最近清理失败时间', }, }, + logs: { + loading: '正在加载请求日志…', + loadFailed: '无法加载请求日志。', + loadMore: '加载更多', + refresh: '刷新', + refreshFailed: '刷新请求日志失败;已保留当前所有日志页。', + caption: '请求日志', + empty: { + title: '尚无请求日志', + description: '收到客户端请求后,脱敏日志会显示在这里。', + filteredTitle: '没有匹配当前筛选条件的日志', + filteredDescription: '调整或重置筛选条件后重试。', + }, + options: { + groupsFailed: '无法加载 Group 筛选选项;日志列表不受影响。', + accessKeysFailed: '无法加载 AccessKey 筛选选项;日志列表不受影响。', + }, + filters: { + label: '请求日志筛选', + from: '开始时间', + to: '结束时间', + group: 'Group', + groupHelp: '匹配任一尝试使用过的 Group。', + anyGroup: '全部 Group', + model: '客户端模型', + accessKey: 'AccessKey', + anyAccessKey: '全部 AccessKey', + status: '状态', + anyStatus: '全部状态', + requestId: '请求 ID', + apply: '应用', + reset: '重置', + }, + errors: { + dateTime: '请输入有效的本地日期时间。', + range: '结束时间必须晚于开始时间。', + positiveId: '请选择正的有效 ID。', + status: '请选择有效状态。', + requestId: '请输入规范的小写 UUIDv4。', + }, + accessKey: { + named: '{name} · #{id}', + deleted: '已删除 · #{id}', + }, + none: '无', + yes: '是', + no: '否', + details: '查看详情', + failureCategory: { + ok: '正常', + rate_limited: '触发限流', + model_unavailable: '模型不可用', + invalid_key: '密钥无效', + upstream_host_error: '上游主机错误', + client_error: '客户端错误', + downstream_cancel: '下游取消', + ambiguous: '原因不明确', + unknown: '未知分类', + }, + action: { + terminate: '终止', + retry: '重试', + cooldown_key: '冷却 Key', + fail_key: '拉黑 Key', + skip_group: '跳过 Group', + unknown: '未知动作', + }, + drawer: { + title: '请求日志详情', + description: '查看最终结果和脱敏的上游尝试链。', + close: '关闭请求日志详情', + summary: '最终结果', + requestId: '请求 ID', + copyRequestId: '复制请求 ID', + completedAt: '完成时间', + status: '状态', + protocol: '协议', + accessKey: 'AccessKey', + clientModel: '客户端模型', + upstreamModel: '上游模型', + statusCode: '状态码', + duration: '耗时', + affinity: '命中亲和', + errorCode: '错误码', + errorSummary: '脱敏错误摘要', + openInspector: '在 Route Inspector 中检查', + attempts: '尝试链', + noAttempts: '此请求没有上游尝试。', + attempt: '尝试 #{sequence}', + committed: '已提交', + notCommitted: '未提交', + group: '历史 Group', + upstreamKey: '历史上游 Key', + failureCategory: '失败分类', + action: '动作', + willRetry: '将重试', + }, + columns: { + completedAt: '完成时间', + requestId: '请求 ID', + model: '客户端模型', + accessKey: 'AccessKey', + status: '状态', + duration: '耗时', + actions: '操作', + }, + status: { + success: '成功', + error: '错误', + incomplete: '未完成', + canceled: '已取消', + }, + }, }, group: { invalidTitle: 'Group 地址无效',