feat(monitor): 增加监控控制面客户端

This commit is contained in:
tbphp committed 2026-07-26 00:09:57 +08:00
1 parent 93e444d5d8
commit 465e761a03
11 files changed
+291 -2

No files matched your search

+20
View File
@@ -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')
})
})
+9 -1
View File
@@ -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,
+25
View File
@@ -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 },
)
})
})
+95
View File
@@ -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 })
}
+20
View File
@@ -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,
})
})
})
+73
View File
@@ -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,
})
}
+6
View File
@@ -93,3 +93,9 @@ export interface AccessKeyDto {
filters: AccessKeyFiltersDto
rpm_limit: number
}
export interface AccessKeyOptionDto {
id: number
name: string
status: AccessKeyDto['status']
}
+9
View File
@@ -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')
+6
View File
@@ -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,
+23
View File
@@ -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')
})
})
+5 -1
View File
@@ -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>