From 465e761a034cf6eb150aec8fb82647e293d72cf5 Mon Sep 17 00:00:00 2001 From: tbphp Date: Sun, 26 Jul 2026 00:09:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(monitor):=20=E5=A2=9E=E5=8A=A0=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E6=8E=A7=E5=88=B6=E9=9D=A2=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/api/control/access-keys.test.ts | 20 +++++ web/src/api/control/access-keys.ts | 10 ++- web/src/api/control/request-logs.test.ts | 25 ++++++ web/src/api/control/request-logs.ts | 95 +++++++++++++++++++++++ web/src/api/control/route-inspect.test.ts | 20 +++++ web/src/api/control/route-inspect.ts | 73 +++++++++++++++++ web/src/api/control/types.ts | 6 ++ web/src/app/query-keys.test.ts | 9 +++ web/src/app/query-keys.ts | 6 ++ web/src/components/ui/AppSelect.test.ts | 23 ++++++ web/src/components/ui/AppSelect.vue | 6 +- 11 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 web/src/api/control/request-logs.test.ts create mode 100644 web/src/api/control/request-logs.ts create mode 100644 web/src/api/control/route-inspect.test.ts create mode 100644 web/src/api/control/route-inspect.ts create mode 100644 web/src/components/ui/AppSelect.test.ts diff --git a/web/src/api/control/access-keys.test.ts b/web/src/api/control/access-keys.test.ts index 5fa751e4..cbe2a26d 100644 --- a/web/src/api/control/access-keys.test.ts +++ b/web/src/api/control/access-keys.test.ts @@ -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') + }) }) diff --git a/web/src/api/control/access-keys.ts b/web/src/api/control/access-keys.ts index 490d4b02..7dbca4a2 100644 --- a/web/src/api/control/access-keys.ts +++ b/web/src/api/control/access-keys.ts @@ -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('/api/access-keys', { method: 'GET', signal }) } +export async function listAccessKeyOptions( + client: ApiClient, + signal?: AbortSignal, +): Promise { + const keys = await listAccessKeys(client, signal) + return keys.map(({ id, name, status }) => ({ id, name, status })) +} + export function createAccessKey( client: ApiClient, body: CreateAccessKeyRequest, diff --git a/web/src/api/control/request-logs.test.ts b/web/src/api/control/request-logs.test.ts new file mode 100644 index 00000000..24a99417 --- /dev/null +++ b/web/src/api/control/request-logs.test.ts @@ -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 }, + ) + }) +}) diff --git a/web/src/api/control/request-logs.ts b/web/src/api/control/request-logs.ts new file mode 100644 index 00000000..b95f270c --- /dev/null +++ b/web/src/api/control/request-logs.ts @@ -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 { + 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(path, { method: 'GET', signal }) +} diff --git a/web/src/api/control/route-inspect.test.ts b/web/src/api/control/route-inspect.test.ts new file mode 100644 index 00000000..8c533e3c --- /dev/null +++ b/web/src/api/control/route-inspect.test.ts @@ -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, + }) + }) +}) diff --git a/web/src/api/control/route-inspect.ts b/web/src/api/control/route-inspect.ts new file mode 100644 index 00000000..32b896ee --- /dev/null +++ b/web/src/api/control/route-inspect.ts @@ -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 { + return client.request('/api/route/inspect', { + method: 'POST', + json: body, + signal, + }) +} diff --git a/web/src/api/control/types.ts b/web/src/api/control/types.ts index 0f9b03a4..48eb2b29 100644 --- a/web/src/api/control/types.ts +++ b/web/src/api/control/types.ts @@ -93,3 +93,9 @@ export interface AccessKeyDto { filters: AccessKeyFiltersDto rpm_limit: number } + +export interface AccessKeyOptionDto { + id: number + name: string + status: AccessKeyDto['status'] +} diff --git a/web/src/app/query-keys.test.ts b/web/src/app/query-keys.test.ts index 888f30f6..924e4140 100644 --- a/web/src/app/query-keys.test.ts +++ b/web/src/app/query-keys.test.ts @@ -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') diff --git a/web/src/app/query-keys.ts b/web/src/app/query-keys.ts index a28a8810..8cbec267 100644 --- a/web/src/app/query-keys.ts +++ b/web/src/app/query-keys.ts @@ -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, diff --git a/web/src/components/ui/AppSelect.test.ts b/web/src/components/ui/AppSelect.test.ts new file mode 100644 index 00000000..4b746a82 --- /dev/null +++ b/web/src/components/ui/AppSelect.test.ts @@ -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') + }) +}) diff --git a/web/src/components/ui/AppSelect.vue b/web/src/components/ui/AppSelect.vue index ac7726f0..c0214d86 100644 --- a/web/src/components/ui/AppSelect.vue +++ b/web/src/components/ui/AppSelect.vue @@ -1,5 +1,6 @@