feat(monitor): 实现请求日志视图
This commit is contained in:
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>
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 アドレスが無効です',
|
||||
|
||||
@@ -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 地址无效',
|
||||
|
||||
Reference in new issue
Block a user