feat(monitor): 增加监控控制面客户端
This commit is contained in:
11 files changed
+291
-2
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
createAccessKey,
|
||||
deleteAccessKey,
|
||||
listAccessKeys,
|
||||
listAccessKeyOptions,
|
||||
updateAccessKey,
|
||||
type CreateAccessKeyRequest,
|
||||
type UpdateAccessKeyRequest,
|
||||
@@ -39,4 +40,23 @@ describe('AccessKey control API', () => {
|
||||
])
|
||||
expect(createBody).not.toHaveProperty('status')
|
||||
})
|
||||
|
||||
it('projects safe AccessKey options without retaining plaintext credentials', async () => {
|
||||
const request = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 12,
|
||||
name: 'Client',
|
||||
key: 'sk-gl-CANARY',
|
||||
status: 'active',
|
||||
filters: { groups: [], protocols: [], models: [] },
|
||||
rpm_limit: 0,
|
||||
},
|
||||
]) as ApiClient['request']
|
||||
const client: ApiClient = { request }
|
||||
|
||||
expect(await listAccessKeyOptions(client)).toEqual([
|
||||
{ id: 12, name: 'Client', status: 'active' },
|
||||
])
|
||||
expect(JSON.stringify(await listAccessKeyOptions(client))).not.toContain('sk-gl-CANARY')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ApiClient } from '@/api/client'
|
||||
|
||||
import type { AccessKeyDto, AccessKeyFiltersDto } from './types'
|
||||
import type { AccessKeyDto, AccessKeyFiltersDto, AccessKeyOptionDto } from './types'
|
||||
|
||||
export interface CreateAccessKeyRequest {
|
||||
name: string
|
||||
@@ -19,6 +19,14 @@ export function listAccessKeys(client: ApiClient, signal?: AbortSignal): Promise
|
||||
return client.request<AccessKeyDto[]>('/api/access-keys', { method: 'GET', signal })
|
||||
}
|
||||
|
||||
export async function listAccessKeyOptions(
|
||||
client: ApiClient,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AccessKeyOptionDto[]> {
|
||||
const keys = await listAccessKeys(client, signal)
|
||||
return keys.map(({ id, name, status }) => ({ id, name, status }))
|
||||
}
|
||||
|
||||
export function createAccessKey(
|
||||
client: ApiClient,
|
||||
body: CreateAccessKeyRequest,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ApiClient } from '@/api/client'
|
||||
|
||||
import { listRequestLogs } from './request-logs'
|
||||
|
||||
describe('Request log control API', () => {
|
||||
it('serializes supported filters in the approved order and forwards AbortSignal', async () => {
|
||||
const signal = new AbortController().signal
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ items: [], next_cursor: null }) as ApiClient['request']
|
||||
const client: ApiClient = { request }
|
||||
|
||||
await listRequestLogs(
|
||||
client,
|
||||
{ from: '2026-07-25T10:00:00.000Z', group_id: 7, status: 'error' },
|
||||
'opaque',
|
||||
signal,
|
||||
)
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'/api/logs?from=2026-07-25T10%3A00%3A00.000Z&group_id=7&status=error&cursor=opaque',
|
||||
{ method: 'GET', signal },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ApiClient } from '@/api/client'
|
||||
|
||||
import type { AccessProtocol } from './types'
|
||||
|
||||
export type RequestLogStatus = 'success' | 'error' | 'incomplete' | 'canceled'
|
||||
|
||||
export type FailureCategory =
|
||||
| 'ok'
|
||||
| 'rate_limited'
|
||||
| 'model_unavailable'
|
||||
| 'invalid_key'
|
||||
| 'upstream_host_error'
|
||||
| 'client_error'
|
||||
| 'downstream_cancel'
|
||||
| 'ambiguous'
|
||||
|
||||
export type RequestLogAction = 'terminate' | 'retry' | 'cooldown_key' | 'fail_key' | 'skip_group'
|
||||
|
||||
export interface RequestLogFilters {
|
||||
from?: string
|
||||
to?: string
|
||||
group_id?: number
|
||||
model?: string
|
||||
access_key_id?: number
|
||||
status?: RequestLogStatus
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
export interface RequestLogAttemptDto {
|
||||
sequence: number
|
||||
group_id: number
|
||||
group_name: string
|
||||
key_id: number
|
||||
key_mask: string
|
||||
upstream_model: string
|
||||
status_code: number
|
||||
duration_ms: number
|
||||
failure_category: FailureCategory
|
||||
action: RequestLogAction
|
||||
will_retry: boolean
|
||||
error_code: string
|
||||
error_summary: string
|
||||
committed: boolean
|
||||
}
|
||||
|
||||
export interface RequestLogItemDto {
|
||||
request_id: string
|
||||
completed_at: string
|
||||
access_key: {
|
||||
id: number
|
||||
name: string | null
|
||||
deleted: boolean
|
||||
}
|
||||
protocol: AccessProtocol
|
||||
client_model: string
|
||||
upstream_model: string
|
||||
status: RequestLogStatus
|
||||
status_code: number
|
||||
duration_ms: number
|
||||
error_code: string
|
||||
error_summary: string
|
||||
affinity_hit: boolean
|
||||
attempts: RequestLogAttemptDto[]
|
||||
}
|
||||
|
||||
export interface RequestLogPageDto {
|
||||
items: RequestLogItemDto[]
|
||||
next_cursor: string | null
|
||||
}
|
||||
|
||||
export function listRequestLogs(
|
||||
client: ApiClient,
|
||||
filters: RequestLogFilters,
|
||||
cursor?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestLogPageDto> {
|
||||
const params = new URLSearchParams()
|
||||
const values: Array<[string, string | number | undefined]> = [
|
||||
['from', filters.from],
|
||||
['to', filters.to],
|
||||
['group_id', filters.group_id],
|
||||
['model', filters.model],
|
||||
['access_key_id', filters.access_key_id],
|
||||
['status', filters.status],
|
||||
['request_id', filters.request_id],
|
||||
['cursor', cursor],
|
||||
]
|
||||
for (const [key, value] of values) {
|
||||
if (value !== undefined) params.append(key, String(value))
|
||||
}
|
||||
const query = params.toString()
|
||||
const path: `/api/${string}` = query === '' ? '/api/logs' : `/api/logs?${query}`
|
||||
|
||||
return client.request<RequestLogPageDto>(path, { method: 'GET', signal })
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ApiClient } from '@/api/client'
|
||||
|
||||
import { inspectRoute } from './route-inspect'
|
||||
|
||||
describe('Route inspect control API', () => {
|
||||
it('posts the exact route-inspection request and forwards AbortSignal', async () => {
|
||||
const signal = new AbortController().signal
|
||||
const request = vi.fn().mockResolvedValue({}) as ApiClient['request']
|
||||
const client: ApiClient = { request }
|
||||
const body = { protocol: 'openai' as const, external_model: 'gpt-real', access_key_id: 12 }
|
||||
|
||||
await inspectRoute(client, body, signal)
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/api/route/inspect', {
|
||||
method: 'POST',
|
||||
json: { protocol: 'openai', external_model: 'gpt-real', access_key_id: 12 },
|
||||
signal,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ApiClient } from '@/api/client'
|
||||
|
||||
import type { AccessKeyDto, AccessProtocol } from './types'
|
||||
|
||||
export type RouteInspectReasonCode =
|
||||
| 'access_key_disabled'
|
||||
| 'protocol_filtered'
|
||||
| 'model_filtered'
|
||||
| 'no_route_target'
|
||||
| 'group_disabled'
|
||||
| 'group_filtered'
|
||||
| 'no_available_group'
|
||||
| 'no_keys'
|
||||
| 'group_weight_zero'
|
||||
| 'key_disabled'
|
||||
| 'key_blacklisted'
|
||||
| 'key_cooldown'
|
||||
| 'key_weight_zero'
|
||||
| 'no_available_key'
|
||||
|
||||
export interface RouteInspectRequest {
|
||||
protocol: AccessProtocol
|
||||
external_model: string
|
||||
access_key_id: number
|
||||
}
|
||||
|
||||
export interface RouteInspectKeyDto {
|
||||
key_id: number
|
||||
available: boolean
|
||||
reason_code: RouteInspectReasonCode | null
|
||||
weight_manual: number | null
|
||||
weight_auto: number
|
||||
effective_weight: number
|
||||
cooldown_until: string | null
|
||||
}
|
||||
|
||||
export interface RouteInspectGroupDto {
|
||||
group_id: number
|
||||
group_name: string
|
||||
upstream_model: string
|
||||
weight_manual: number | null
|
||||
included: boolean
|
||||
routable: boolean
|
||||
reason_code: RouteInspectReasonCode | null
|
||||
keys: RouteInspectKeyDto[]
|
||||
}
|
||||
|
||||
export interface RouteInspectResponseDto {
|
||||
observed_at: string
|
||||
snapshot_revision: number
|
||||
protocol: AccessProtocol
|
||||
external_model: string
|
||||
access_key: {
|
||||
id: number
|
||||
name: string
|
||||
status: AccessKeyDto['status']
|
||||
}
|
||||
routable: boolean
|
||||
reason_code: RouteInspectReasonCode | null
|
||||
groups: RouteInspectGroupDto[]
|
||||
}
|
||||
|
||||
export function inspectRoute(
|
||||
client: ApiClient,
|
||||
body: RouteInspectRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteInspectResponseDto> {
|
||||
return client.request<RouteInspectResponseDto>('/api/route/inspect', {
|
||||
method: 'POST',
|
||||
json: body,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
@@ -93,3 +93,9 @@ export interface AccessKeyDto {
|
||||
filters: AccessKeyFiltersDto
|
||||
rpm_limit: number
|
||||
}
|
||||
|
||||
export interface AccessKeyOptionDto {
|
||||
id: number
|
||||
name: string
|
||||
status: AccessKeyDto['status']
|
||||
}
|
||||
@@ -14,7 +14,14 @@ describe('controlQueryKeys', () => {
|
||||
expect(controlQueryKeys.groups.keyLists()).toEqual(['control', 'groups', 'keys'])
|
||||
expect(controlQueryKeys.groups.keys(42)).toEqual(['control', 'groups', 'keys', 42])
|
||||
expect(controlQueryKeys.health()).toEqual(['control', 'health'])
|
||||
expect(controlQueryKeys.logs.list({ status: 'error' })).toEqual([
|
||||
'control',
|
||||
'logs',
|
||||
'list',
|
||||
{ status: 'error' },
|
||||
])
|
||||
expect(controlQueryKeys.accessKeys.list()).toEqual(['control', 'access-keys', 'list'])
|
||||
expect(controlQueryKeys.accessKeys.options()).toEqual(['control', 'access-keys', 'options'])
|
||||
expect(controlQueryKeys.settings()).toEqual(['control', 'settings'])
|
||||
expect(controlQueryKeys.systemInfo()).toEqual(['control', 'system-info'])
|
||||
})
|
||||
@@ -23,7 +30,9 @@ describe('controlQueryKeys', () => {
|
||||
const keys = [
|
||||
controlQueryKeys.groups.detail(7),
|
||||
controlQueryKeys.groups.keys(7),
|
||||
controlQueryKeys.logs.list({ access_key_id: 12 }),
|
||||
controlQueryKeys.accessKeys.list(),
|
||||
controlQueryKeys.accessKeys.options(),
|
||||
]
|
||||
|
||||
expect(JSON.stringify(keys)).not.toContain('AUTH_KEY_CANARY')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RequestLogFilters } from '@/api/control/request-logs'
|
||||
|
||||
export const controlQueryKeys = {
|
||||
all: ['control'] as const,
|
||||
groups: {
|
||||
@@ -9,8 +11,12 @@ export const controlQueryKeys = {
|
||||
keys: (id: number) => ['control', 'groups', 'keys', id] as const,
|
||||
},
|
||||
health: () => ['control', 'health'] as const,
|
||||
logs: {
|
||||
list: (filters: RequestLogFilters) => ['control', 'logs', 'list', filters] as const,
|
||||
},
|
||||
accessKeys: {
|
||||
list: () => ['control', 'access-keys', 'list'] as const,
|
||||
options: () => ['control', 'access-keys', 'options'] as const,
|
||||
},
|
||||
settings: () => ['control', 'settings'] as const,
|
||||
systemInfo: () => ['control', 'system-info'] as const,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import AppSelect from './AppSelect.vue'
|
||||
|
||||
describe('AppSelect', () => {
|
||||
it('forwards control accessibility attributes to the combobox trigger', () => {
|
||||
const wrapper = mount(AppSelect, {
|
||||
props: {
|
||||
id: 'protocol',
|
||||
label: 'Protocol',
|
||||
modelValue: 'openai',
|
||||
options: [{ value: 'openai', label: 'OpenAI' }],
|
||||
'aria-describedby': 'protocol-help protocol-error',
|
||||
'aria-invalid': 'true',
|
||||
},
|
||||
})
|
||||
|
||||
const combobox = wrapper.get('[role="combobox"]')
|
||||
expect(combobox.attributes('id')).toBe('protocol')
|
||||
expect(combobox.attributes('aria-describedby')).toBe('protocol-help protocol-error')
|
||||
expect(combobox.attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||
import { useAttrs } from 'vue'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
@@ -16,12 +17,15 @@ interface SelectOption {
|
||||
label: string
|
||||
}
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
label: string
|
||||
options: SelectOption[]
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
const attrs = useAttrs()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,7 +33,7 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
:model-value="modelValue"
|
||||
@update:model-value="(value) => typeof value === 'string' && emit('update:modelValue', value)"
|
||||
>
|
||||
<SelectTrigger class="app-select__trigger" :aria-label="label">
|
||||
<SelectTrigger v-bind="attrs" class="app-select__trigger" :aria-label="label">
|
||||
<SelectValue />
|
||||
<ChevronDown :size="16" aria-hidden="true" />
|
||||
</SelectTrigger>
|
||||
|
||||
Reference in new issue
Block a user