feat(monitor): 实现请求日志视图

This commit is contained in:
tbphp committed 2026-07-26 01:05:18 +08:00
1 parent 77c4fe9843
commit 5d8bdfd653
12 files changed
+2303 -2

No files matched your search

@@ -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> = {},
): 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: '<b data-canary="summary-html">bounded upstream summary</b>',
committed: false,
...overrides,
}
}
function logFixture(overrides: Partial<RequestLogItemDto> = {}): 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: '<script data-canary="log-summary">unsafe()</script>',
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<T extends Element>(selector: string): T {
const element = document.body.querySelector<T>(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<HTMLElement>('[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(
'<script data-canary="log-summary">unsafe()</script>',
)
expect(document.body.textContent).toContain(
'<b data-canary="summary-html">bounded upstream summary</b>',
)
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<HTMLButtonElement>('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<HTMLAnchorElement>('[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')
})
})
@@ -0,0 +1,354 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
FailureCategory,
RequestLogAction,
RequestLogItemDto,
RequestLogStatus,
} from '@/api/control/request-logs'
import AppDrawer from '@/components/ui/AppDrawer.vue'
import CopyButton from '@/components/ui/CopyButton.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const props = defineProps<{
open: boolean
log: RequestLogItemDto | null
}>()
defineEmits<{ 'update:open': [open: boolean] }>()
const { t } = useI18n()
const failureCategories: readonly FailureCategory[] = [
'ok',
'rate_limited',
'model_unavailable',
'invalid_key',
'upstream_host_error',
'client_error',
'downstream_cancel',
'ambiguous',
]
const actions: readonly RequestLogAction[] = [
'terminate',
'retry',
'cooldown_key',
'fail_key',
'skip_group',
]
const orderedAttempts = computed(() =>
[...(props.log?.attempts ?? [])].sort((left, right) => left.sequence - right.sequence),
)
const inspectorTarget = computed(() => {
if (!props.log) return { name: 'monitor', query: { tab: 'inspector' } }
return {
name: 'monitor',
query: {
tab: 'inspector',
protocol: props.log.protocol,
external_model: props.log.client_model,
access_key_id: props.log.access_key.id,
},
}
})
function statusTone(status: RequestLogStatus): 'success' | 'danger' | 'warning' | 'neutral' {
if (status === 'success') return 'success'
if (status === 'error') return 'danger'
if (status === 'incomplete') return 'warning'
return 'neutral'
}
function failureLabel(value: string): string {
return failureCategories.includes(value as FailureCategory)
? t(`monitor.logs.failureCategory.${value}`)
: t('monitor.logs.failureCategory.unknown')
}
function actionLabel(value: string): string {
return actions.includes(value as RequestLogAction)
? t(`monitor.logs.action.${value}`)
: t('monitor.logs.action.unknown')
}
function accessKeyLabel(log: RequestLogItemDto): string {
if (log.access_key.deleted) {
return t('monitor.logs.accessKey.deleted', { id: log.access_key.id })
}
if (log.access_key.name) {
return t('monitor.logs.accessKey.named', {
id: log.access_key.id,
name: log.access_key.name,
})
}
return `#${log.access_key.id}`
}
</script>
<template>
<AppDrawer
:open="open"
:title="t('monitor.logs.drawer.title')"
:description="t('monitor.logs.drawer.description')"
:close-label="t('monitor.logs.drawer.close')"
@update:open="$emit('update:open', $event)"
>
<template #trigger><slot name="trigger" /></template>
<div v-if="log" class="log-detail">
<section class="log-detail__section" aria-labelledby="log-detail-summary-heading">
<h2 id="log-detail-summary-heading">{{ t('monitor.logs.drawer.summary') }}</h2>
<dl class="log-detail__facts">
<div class="log-detail__wide">
<dt>{{ t('monitor.logs.drawer.requestId') }}</dt>
<dd class="log-detail__copy">
<code>{{ log.request_id }}</code>
<CopyButton
:value="log.request_id"
:label="t('monitor.logs.drawer.copyRequestId')"
:success-label="t('common.copied')"
:failure-label="t('common.copyFailed')"
/>
</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.completedAt') }}</dt>
<dd>
<time :datetime="log.completed_at">{{ log.completed_at }}</time>
</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.status') }}</dt>
<dd>
<StatusBadge :tone="statusTone(log.status)">
{{ t(`monitor.logs.status.${log.status}`) }}
</StatusBadge>
</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.protocol') }}</dt>
<dd>{{ t(`group.protocols.${log.protocol}`) }}</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.accessKey') }}</dt>
<dd>{{ accessKeyLabel(log) }}</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.clientModel') }}</dt>
<dd>
<code>{{ log.client_model }}</code>
</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.upstreamModel') }}</dt>
<dd>
<code>{{ log.upstream_model }}</code>
</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.statusCode') }}</dt>
<dd>{{ log.status_code }}</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.duration') }}</dt>
<dd>{{ log.duration_ms }} ms</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.affinity') }}</dt>
<dd>{{ log.affinity_hit ? t('monitor.logs.yes') : t('monitor.logs.no') }}</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.errorCode') }}</dt>
<dd>
<code>{{ log.error_code || t('monitor.logs.none') }}</code>
</dd>
</div>
</dl>
<div class="log-detail__summary">
<h3>{{ t('monitor.logs.drawer.errorSummary') }}</h3>
<p data-test="log-error-summary">{{ log.error_summary || t('monitor.logs.none') }}</p>
</div>
<RouterLink
class="log-detail__inspector"
data-test="log-inspector-link"
:to="inspectorTarget"
>
{{ t('monitor.logs.drawer.openInspector') }}
</RouterLink>
</section>
<section class="log-detail__section" aria-labelledby="log-detail-attempts-heading">
<h2 id="log-detail-attempts-heading">{{ t('monitor.logs.drawer.attempts') }}</h2>
<p v-if="orderedAttempts.length === 0" class="log-detail__empty">
{{ t('monitor.logs.drawer.noAttempts') }}
</p>
<article
v-for="attempt in orderedAttempts"
v-else
:key="attempt.sequence"
class="log-attempt"
:data-test="`log-attempt-${attempt.sequence}`"
>
<header>
<h3>{{ t('monitor.logs.drawer.attempt', { sequence: attempt.sequence }) }}</h3>
<StatusBadge :tone="attempt.committed ? 'success' : 'neutral'">
{{
attempt.committed
? t('monitor.logs.drawer.committed')
: t('monitor.logs.drawer.notCommitted')
}}
</StatusBadge>
</header>
<dl class="log-detail__facts">
<div>
<dt>{{ t('monitor.logs.drawer.group') }}</dt>
<dd>{{ attempt.group_name }} · #{{ attempt.group_id }}</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.upstreamKey') }}</dt>
<dd>
<code>{{ attempt.key_mask }}</code> · #{{ attempt.key_id }}
</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.upstreamModel') }}</dt>
<dd>
<code>{{ attempt.upstream_model }}</code>
</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.statusCode') }}</dt>
<dd>{{ attempt.status_code }}</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.duration') }}</dt>
<dd>{{ attempt.duration_ms }} ms</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.failureCategory') }}</dt>
<dd>{{ failureLabel(attempt.failure_category) }}</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.action') }}</dt>
<dd>{{ actionLabel(attempt.action) }}</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.willRetry') }}</dt>
<dd>{{ attempt.will_retry ? t('monitor.logs.yes') : t('monitor.logs.no') }}</dd>
</div>
<div>
<dt>{{ t('monitor.logs.drawer.errorCode') }}</dt>
<dd>
<code>{{ attempt.error_code || t('monitor.logs.none') }}</code>
</dd>
</div>
</dl>
<div class="log-detail__summary">
<h4>{{ t('monitor.logs.drawer.errorSummary') }}</h4>
<p>{{ attempt.error_summary || t('monitor.logs.none') }}</p>
</div>
</article>
</section>
</div>
</AppDrawer>
</template>
<style scoped>
.log-detail {
display: grid;
gap: var(--space-6);
}
.log-detail__section {
display: grid;
gap: var(--space-3);
}
.log-detail__section h2,
.log-detail__section h3,
.log-detail__section h4,
.log-detail__section p {
margin: 0;
}
.log-detail__section h2 {
font-size: 1rem;
}
.log-detail__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
margin: 0;
}
.log-detail__facts > div {
min-width: 0;
}
.log-detail__facts dt {
color: var(--color-text-muted);
font-size: 0.75rem;
}
.log-detail__facts dd {
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
}
.log-detail__wide {
grid-column: 1 / -1;
}
.log-detail__copy {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.log-detail code,
.log-detail time {
font-family: var(--font-mono);
}
.log-detail__summary,
.log-attempt {
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-surface-secondary);
padding: var(--space-3);
}
.log-detail__summary {
display: grid;
gap: var(--space-2);
}
.log-detail__summary p {
color: var(--color-text-muted);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.log-detail__inspector {
color: var(--color-primary);
font-weight: 650;
}
.log-attempt {
display: grid;
gap: var(--space-3);
}
.log-attempt > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.log-detail__empty {
color: var(--color-text-muted);
}
</style>
+554
View File
@@ -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> = {}): 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<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((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<RequestLogPageDto | Promise<RequestLogPageDto>> = [
{
items: [logFixture()],
next_cursor: null,
},
],
private readonly options: {
groups?: GroupSummary[] | Promise<GroupSummary[]>
accessKeys?: AccessKeyDto[] | Promise<AccessKeyDto[]>
groupsError?: Error
accessKeysError?: Error
} = {},
) {}
request<T>(path: ApiPath, options?: ApiRequestOptions): Promise<T> {
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<RequestLogPageDto>()
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<RequestLogPageDto>()
const filtered = deferred<RequestLogPageDto>()
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<RequestLogPageDto>()
const refreshed = deferred<RequestLogPageDto>()
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<RequestLogPageDto>()
const retry = deferred<RequestLogPageDto>()
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<HTMLButtonElement>(`[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<HTMLButtonElement>(
'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,
)
})
})
+545
View File
@@ -0,0 +1,545 @@
<script setup lang="ts">
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from '@tanstack/vue-query'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useApiClient } from '@/api/client-context'
import { listAccessKeyOptions } from '@/api/control/access-keys'
import { listGroups } from '@/api/control/groups'
import {
listRequestLogs,
type RequestLogItemDto,
type RequestLogPageDto,
} from '@/api/control/request-logs'
import { RequestCancelledError } from '@/api/errors'
import { controlQueryKeys } from '@/app/query-keys'
import AppButton from '@/components/ui/AppButton.vue'
import DataTable from '@/components/ui/DataTable.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import FormField from '@/components/ui/FormField.vue'
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import {
applyLogFilterDraft,
createLogFilterDraft,
parseAppliedLogFilters,
serializeAppliedLogFilters,
validateLogFilterDraft,
type LogFilterDraft,
type LogFilterErrors,
} from './log-filters'
import LogDetailDrawer from './LogDetailDrawer.vue'
const client = useApiClient()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const { t } = useI18n()
const appliedFilters = computed(() => parseAppliedLogFilters(route.query))
const draft = ref(createLogFilterDraft(appliedFilters.value))
const filterErrors = ref<LogFilterErrors>({})
const openDetailID = ref<string | null>(null)
const refreshPending = ref(false)
const refreshFailed = ref(false)
let refreshOwner = 0
let refreshController: AbortController | undefined
let detailFocusTimer: number | undefined
const groupsQuery = useQuery({
queryKey: controlQueryKeys.groups.list(),
queryFn: ({ signal }) => listGroups(client, signal),
})
const accessKeyOptionsQuery = useQuery({
queryKey: controlQueryKeys.accessKeys.options(),
queryFn: ({ signal }) => listAccessKeyOptions(client, signal),
gcTime: 0,
})
const logsQuery = useInfiniteQuery({
queryKey: computed(() => controlQueryKeys.logs.list(appliedFilters.value)),
initialPageParam: null as string | null,
queryFn: ({ pageParam, signal }) =>
listRequestLogs(client, appliedFilters.value, pageParam ?? undefined, signal),
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
gcTime: 0,
})
const logs = computed(() => {
const unique: RequestLogItemDto[] = []
const seen = new Set<string>()
for (const page of logsQuery.data.value?.pages ?? []) {
for (const log of page.items) {
if (seen.has(log.request_id)) continue
seen.add(log.request_id)
unique.push(log)
}
}
return unique
})
const hasAppliedFilters = computed(() => Object.keys(appliedFilters.value).length > 0)
watch(
() => route.query,
() => {
cancelManualRefresh()
draft.value = createLogFilterDraft(appliedFilters.value)
filterErrors.value = {}
},
)
function statusTone(
status: 'success' | 'error' | 'incomplete' | 'canceled',
): 'success' | 'danger' | 'warning' | 'neutral' {
if (status === 'success') return 'success'
if (status === 'error') return 'danger'
if (status === 'incomplete') return 'warning'
return 'neutral'
}
function accessKeyLabel(log: RequestLogItemDto): string {
if (log.access_key.deleted) {
return t('monitor.logs.accessKey.deleted', { id: log.access_key.id })
}
if (log.access_key.name) {
return t('monitor.logs.accessKey.named', {
id: log.access_key.id,
name: log.access_key.name,
})
}
return `#${log.access_key.id}`
}
function loadMore(): void {
if (!logsQuery.hasNextPage.value || logsQuery.isFetchingNextPage.value) return
void logsQuery.fetchNextPage()
}
async function applyFilters(): Promise<void> {
const errors = validateLogFilterDraft(draft.value)
filterErrors.value = errors
if (Object.keys(errors).length > 0) return
await router.push({
name: 'monitor',
query: serializeAppliedLogFilters(applyLogFilterDraft(draft.value)),
})
}
function filterError(field: keyof LogFilterDraft): string | undefined {
const key = filterErrors.value[field]
return key ? t(key) : undefined
}
async function resetFilters(): Promise<void> {
cancelManualRefresh()
filterErrors.value = {}
draft.value = createLogFilterDraft()
await router.push({ name: 'monitor', query: { tab: 'logs' } })
}
function setDetailOpen(requestID: string, open: boolean): void {
openDetailID.value = open ? requestID : null
if (open) return
window.clearTimeout(detailFocusTimer)
detailFocusTimer = window.setTimeout(() => {
document.getElementById(`log-details-${requestID}`)?.focus()
}, 30)
}
function cancelManualRefresh(): void {
refreshOwner += 1
refreshController?.abort()
refreshController = undefined
refreshPending.value = false
refreshFailed.value = false
}
async function refreshFirstPage(): Promise<void> {
if (refreshPending.value) return
const owner = ++refreshOwner
const filters = { ...appliedFilters.value }
const queryKey = controlQueryKeys.logs.list(filters)
refreshPending.value = true
refreshFailed.value = false
await queryClient.cancelQueries({ queryKey, exact: true })
if (owner !== refreshOwner) return
const controller = new AbortController()
refreshController = controller
try {
const page = await listRequestLogs(client, filters, undefined, controller.signal)
if (owner !== refreshOwner || controller.signal.aborted) return
queryClient.setQueryData<InfiniteData<RequestLogPageDto, string | null>>(queryKey, {
pages: [page],
pageParams: [null],
})
} catch (error: unknown) {
if (
owner === refreshOwner &&
!controller.signal.aborted &&
!(error instanceof RequestCancelledError)
) {
refreshFailed.value = true
}
} finally {
if (owner === refreshOwner) {
refreshController = undefined
refreshPending.value = false
}
}
}
onBeforeUnmount(() => {
cancelManualRefresh()
window.clearTimeout(detailFocusTimer)
})
</script>
<template>
<div class="logs-tab">
<form
class="logs-filter-form"
data-test="logs-filter-form"
:aria-label="t('monitor.logs.filters.label')"
@submit.prevent="applyFilters"
>
<div class="logs-filter-grid">
<FormField
id="logs-from"
:label="t('monitor.logs.filters.from')"
:error="filterError('from')"
>
<template #default="{ describedBy }">
<input
id="logs-from"
v-model="draft.from"
data-test="logs-from"
type="datetime-local"
:aria-describedby="describedBy"
/>
</template>
</FormField>
<FormField id="logs-to" :label="t('monitor.logs.filters.to')" :error="filterError('to')">
<template #default="{ describedBy }">
<input
id="logs-to"
v-model="draft.to"
data-test="logs-to"
type="datetime-local"
:aria-describedby="describedBy"
/>
</template>
</FormField>
<FormField
id="logs-group"
:label="t('monitor.logs.filters.group')"
:description="t('monitor.logs.filters.groupHelp')"
:error="filterError('group_id')"
>
<template #default="{ describedBy }">
<select
id="logs-group"
v-model="draft.group_id"
data-test="logs-group"
:aria-describedby="describedBy"
>
<option value="">{{ t('monitor.logs.filters.anyGroup') }}</option>
<option
v-if="
draft.group_id &&
!groupsQuery.data.value?.some((group) => String(group.id) === draft.group_id)
"
:value="draft.group_id"
>
#{{ draft.group_id }}
</option>
<option
v-for="group in groupsQuery.data.value ?? []"
:key="group.id"
:value="String(group.id)"
>
{{ group.name }} · #{{ group.id }}
</option>
</select>
</template>
</FormField>
<FormField
id="logs-model"
:label="t('monitor.logs.filters.model')"
:error="filterError('model')"
>
<template #default="{ describedBy }">
<input
id="logs-model"
v-model="draft.model"
data-test="logs-model"
type="text"
autocomplete="off"
:aria-describedby="describedBy"
/>
</template>
</FormField>
<FormField
id="logs-access-key"
:label="t('monitor.logs.filters.accessKey')"
:error="filterError('access_key_id')"
>
<template #default="{ describedBy }">
<select
id="logs-access-key"
v-model="draft.access_key_id"
data-test="logs-access-key"
:aria-describedby="describedBy"
>
<option value="">{{ t('monitor.logs.filters.anyAccessKey') }}</option>
<option
v-if="
draft.access_key_id &&
!accessKeyOptionsQuery.data.value?.some(
(key) => String(key.id) === draft.access_key_id,
)
"
:value="draft.access_key_id"
>
#{{ draft.access_key_id }}
</option>
<option
v-for="key in accessKeyOptionsQuery.data.value ?? []"
:key="key.id"
:value="String(key.id)"
>
{{ key.name }} · #{{ key.id }}
</option>
</select>
</template>
</FormField>
<FormField
id="logs-status"
:label="t('monitor.logs.filters.status')"
:error="filterError('status')"
>
<template #default="{ describedBy }">
<select
id="logs-status"
v-model="draft.status"
data-test="logs-status"
:aria-describedby="describedBy"
>
<option value="">{{ t('monitor.logs.filters.anyStatus') }}</option>
<option value="success">{{ t('monitor.logs.status.success') }}</option>
<option value="error">{{ t('monitor.logs.status.error') }}</option>
<option value="incomplete">{{ t('monitor.logs.status.incomplete') }}</option>
<option value="canceled">{{ t('monitor.logs.status.canceled') }}</option>
</select>
</template>
</FormField>
<FormField
id="logs-request-id"
:label="t('monitor.logs.filters.requestId')"
:error="filterError('request_id')"
>
<template #default="{ describedBy }">
<input
id="logs-request-id"
v-model="draft.request_id"
data-test="logs-request-id"
type="text"
autocomplete="off"
:aria-describedby="describedBy"
/>
</template>
</FormField>
</div>
<div class="logs-filter-actions">
<AppButton type="submit">{{ t('monitor.logs.filters.apply') }}</AppButton>
<AppButton data-test="logs-reset" variant="ghost" @click="resetFilters">
{{ t('monitor.logs.filters.reset') }}
</AppButton>
<AppButton
data-test="logs-refresh"
variant="secondary"
:busy="refreshPending"
@click="refreshFirstPage"
>
{{ t('monitor.logs.refresh') }}
</AppButton>
</div>
</form>
<InlineFeedback
v-if="groupsQuery.isError.value"
data-test="logs-group-options-failed"
tone="warning"
>
{{ t('monitor.logs.options.groupsFailed') }}
</InlineFeedback>
<InlineFeedback
v-if="accessKeyOptionsQuery.isError.value"
data-test="logs-access-key-options-failed"
tone="warning"
>
{{ t('monitor.logs.options.accessKeysFailed') }}
</InlineFeedback>
<div v-if="refreshFailed" class="logs-refresh-failed" data-test="logs-refresh-failed">
<InlineFeedback tone="danger">{{ t('monitor.logs.refreshFailed') }}</InlineFeedback>
<AppButton data-test="logs-refresh-retry" variant="secondary" @click="refreshFirstPage">
{{ t('common.retry') }}
</AppButton>
</div>
<QueryFeedback
v-if="logsQuery.isPending.value"
state="loading"
:message="t('monitor.logs.loading')"
/>
<QueryFeedback
v-else-if="logsQuery.isError.value && !logsQuery.data.value"
state="error"
:message="t('monitor.logs.loadFailed')"
:retry-label="t('common.retry')"
@retry="logsQuery.refetch()"
/>
<DataTable v-else-if="logs.length > 0" :caption="t('monitor.logs.caption')" dense>
<thead>
<tr>
<th scope="col">{{ t('monitor.logs.columns.completedAt') }}</th>
<th scope="col">{{ t('monitor.logs.columns.requestId') }}</th>
<th scope="col">{{ t('monitor.logs.columns.model') }}</th>
<th scope="col">{{ t('monitor.logs.columns.accessKey') }}</th>
<th scope="col">{{ t('monitor.logs.columns.status') }}</th>
<th scope="col">{{ t('monitor.logs.columns.duration') }}</th>
<th scope="col">{{ t('monitor.logs.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.request_id" :data-test="`log-row-${log.request_id}`">
<td>
<time :datetime="log.completed_at">{{ log.completed_at }}</time>
</td>
<td>
<code>{{ log.request_id }}</code>
</td>
<td>
<code>{{ log.client_model }}</code>
</td>
<td>{{ accessKeyLabel(log) }}</td>
<td>
<StatusBadge :tone="statusTone(log.status)">
{{ t(`monitor.logs.status.${log.status}`) }}
</StatusBadge>
</td>
<td>{{ log.duration_ms }} ms</td>
<td>
<LogDetailDrawer
:open="openDetailID === log.request_id"
:log="log"
@update:open="setDetailOpen(log.request_id, $event)"
>
<template #trigger>
<AppButton
:id="`log-details-${log.request_id}`"
:data-test="`log-details-${log.request_id}`"
variant="ghost"
>
{{ t('monitor.logs.details') }}
</AppButton>
</template>
</LogDetailDrawer>
</td>
</tr>
</tbody>
</DataTable>
<EmptyState
v-else-if="logsQuery.data.value"
:data-test="hasAppliedFilters ? 'logs-empty-filtered' : 'logs-empty-unfiltered'"
:title="
t(hasAppliedFilters ? 'monitor.logs.empty.filteredTitle' : 'monitor.logs.empty.title')
"
:description="
t(
hasAppliedFilters
? 'monitor.logs.empty.filteredDescription'
: 'monitor.logs.empty.description',
)
"
/>
<AppButton
v-if="logsQuery.hasNextPage.value"
data-test="logs-load-more"
variant="secondary"
:busy="logsQuery.isFetchingNextPage.value"
@click="loadMore"
>
{{ t('monitor.logs.loadMore') }}
</AppButton>
</div>
</template>
<style scoped>
.logs-tab {
display: grid;
gap: var(--space-4);
}
.logs-filter-form {
display: grid;
gap: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
background: var(--color-surface);
padding: var(--space-4);
box-shadow: var(--shadow-card);
}
.logs-filter-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: var(--space-3);
}
.logs-filter-form input,
.logs-filter-form select {
width: 100%;
min-height: 44px;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
padding: 8px 10px;
font: inherit;
}
.logs-filter-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.logs-refresh-failed {
display: flex;
align-items: center;
gap: var(--space-2);
}
.logs-tab code,
.logs-tab time {
font-family: var(--font-mono);
}
.logs-tab > :deep(.app-button) {
justify-self: center;
}
@media (max-width: 900px) {
.logs-filter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.logs-filter-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -17,6 +17,9 @@ async function mountMonitor(path: string) {
HealthTab: {
template: '<div data-test="monitor-health-content" />',
},
LogsTab: {
template: '<div data-test="monitor-logs-content" />',
},
},
},
})
@@ -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')
+4 -1
View File
@@ -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 {
<div v-if="activeTab === 'health'" data-test="monitor-health-slot">
<HealthTab />
</div>
<div v-else-if="activeTab === 'logs'" data-test="monitor-logs-slot" />
<div v-else-if="activeTab === 'logs'" data-test="monitor-logs-slot">
<LogsTab />
</div>
<div v-else data-test="monitor-inspector-slot" />
</AppTabs>
</div>
@@ -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({})
})
})
+168
View File
@@ -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<Record<keyof LogFilterDraft, string>>
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<string, unknown>): 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
}
+6 -1
View File
@@ -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 =
+113
View File
@@ -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',
+114
View File
@@ -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 アドレスが無効です',
+113
View File
@@ -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 地址无效',