mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
perf(observability): narrow usage and ops read models
Separate Usage list and detail reads, use keyset paging with first-page totals, push Ops predicates into source branches, and merge repeated account and diagnostics aggregates. Apply the shared per-query PostgreSQL budget, coalesce identical Dashboard windows, and preserve the existing IP, User-Agent, and latency detail surface. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { UsageRecord } from './usage'
|
||||
import type { UsageListRecord } from './usage'
|
||||
import request from '../request'
|
||||
|
||||
export type DashboardTrendKind = 'usage' | 'latency' | 'errors'
|
||||
@@ -170,7 +170,7 @@ export interface DashboardSummaryResponse {
|
||||
healthTimeline: DashboardHealthTimeline
|
||||
wireProfiles: DashboardWireProfile[]
|
||||
accountUsage: DashboardAccountUsage[]
|
||||
usageRecords: UsageRecord[]
|
||||
usageRecords: UsageListRecord[]
|
||||
poolSummary: DashboardPoolSummary
|
||||
capacityInfo: DashboardCapacityInfo
|
||||
rotationStrategy: string
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import request from '../request'
|
||||
|
||||
export interface UsagePageMeta {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface UsageTokenDetails {
|
||||
inputTokens: number | null
|
||||
outputTokens: number | null
|
||||
@@ -73,6 +66,35 @@ export interface UsageRecordMetadata {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UsageListRecord {
|
||||
id: string
|
||||
provider: string | null
|
||||
authenticationKind: string | null
|
||||
accountId: string | null
|
||||
accountEmail: string | null
|
||||
accountName: string | null
|
||||
route: string
|
||||
model: string | null
|
||||
requestedModel: string | null
|
||||
upstreamModel: string | null
|
||||
serviceTier: string | null
|
||||
clientTransport: string
|
||||
upstreamTransport: string | null
|
||||
reasoningEffort: string | null
|
||||
reasoningPreset: string | null
|
||||
subagentKind: string | null
|
||||
compact: boolean
|
||||
tokenDetails: UsageTokenDetails
|
||||
billing: UsageBilling | null
|
||||
latencyDetails: UsageLatencyDetails
|
||||
firstTokenLatencyMs: number | null
|
||||
latencyMs: number | null
|
||||
createdAt: string
|
||||
createdAtDisplay: string
|
||||
clientIp: string | null
|
||||
userAgent: string | null
|
||||
}
|
||||
|
||||
export interface UsageRecord {
|
||||
id: string
|
||||
requestId: string
|
||||
@@ -171,9 +193,10 @@ export type UsageRecordDetail = UsageRecord & {
|
||||
}
|
||||
|
||||
export interface UsageRecordsResponse {
|
||||
items: UsageRecord[]
|
||||
page: UsagePageMeta
|
||||
nextCursor: string | null
|
||||
items: UsageListRecord[]
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface OpsErrorMetadata {
|
||||
@@ -209,8 +232,9 @@ export interface OpsError {
|
||||
|
||||
export interface OpsErrorsResponse {
|
||||
items: OpsError[]
|
||||
page: UsagePageMeta
|
||||
nextCursor: string | null
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface UsageSummaryResponse {
|
||||
@@ -370,36 +394,110 @@ interface UsageRangeQuery {
|
||||
search?: string
|
||||
}
|
||||
|
||||
type UsagePagedQuery = UsageRangeQuery & {
|
||||
page: number
|
||||
interface PageQuery {
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
type OpsErrorPagedQuery = UsagePagedQuery & {
|
||||
type UsagePageQuery = UsageRangeQuery & PageQuery
|
||||
|
||||
type OpsErrorPageQuery = UsagePageQuery & {
|
||||
failureClass?: string
|
||||
route?: string
|
||||
}
|
||||
|
||||
interface CursorPageResponse<Item> {
|
||||
items: Item[]
|
||||
total: number | null
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
type CursorPageQuery<Query> = Query & {
|
||||
pageSize: number
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
interface UsageDetailQuery {
|
||||
id: string
|
||||
}
|
||||
|
||||
type UsageDiagnosticsQuery = UsageRangeQuery & { dimension: string }
|
||||
|
||||
export function getUsageRecords(data: UsagePagedQuery) {
|
||||
return request<UsageRecordsResponse>({
|
||||
url: '/api/admin/usage/records',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
})
|
||||
export function createUsageRecordsPager() {
|
||||
return createPageLoader<UsageListRecord, UsageRangeQuery>(data =>
|
||||
request<CursorPageResponse<UsageListRecord>>({
|
||||
url: '/api/admin/usage/records',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getOpsErrors(data: OpsErrorPagedQuery) {
|
||||
return request<OpsErrorsResponse>({
|
||||
url: '/api/admin/operations/errors',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
})
|
||||
export function createOpsErrorsPager() {
|
||||
return createPageLoader<OpsError, Omit<OpsErrorPageQuery, keyof PageQuery>>(data =>
|
||||
request<CursorPageResponse<OpsError>>({
|
||||
url: '/api/admin/operations/errors',
|
||||
method: 'GET',
|
||||
params: data,
|
||||
}))
|
||||
}
|
||||
|
||||
function createPageLoader<Item, Query extends object>(
|
||||
fetchPage: (query: CursorPageQuery<Query>) => Promise<CursorPageResponse<Item>>,
|
||||
) {
|
||||
const cursors = new Map<number, string | undefined>([[1, undefined]])
|
||||
let queryKey = ''
|
||||
let total = 0
|
||||
|
||||
function reset(nextQueryKey: string) {
|
||||
cursors.clear()
|
||||
cursors.set(1, undefined)
|
||||
queryKey = nextQueryKey
|
||||
total = 0
|
||||
}
|
||||
|
||||
async function load(data: Query & PageQuery) {
|
||||
const { currentPage, pageSize, ...query } = data
|
||||
const nextQueryKey = JSON.stringify([pageSize, query])
|
||||
if (queryKey !== nextQueryKey)
|
||||
reset(nextQueryKey)
|
||||
|
||||
const requestedPage = Math.max(1, Math.trunc(currentPage))
|
||||
let page = [...cursors.keys()].reduce(
|
||||
(known, candidate) => candidate <= requestedPage && candidate > known ? candidate : known,
|
||||
1,
|
||||
)
|
||||
let result: CursorPageResponse<Item>
|
||||
|
||||
while (page < requestedPage) {
|
||||
result = await fetchPage({
|
||||
...(query as Query),
|
||||
pageSize,
|
||||
cursor: cursors.get(page),
|
||||
})
|
||||
if (result.total != null)
|
||||
total = result.total
|
||||
if (!result.nextCursor)
|
||||
return { items: result.items, currentPage: page, pageSize, total }
|
||||
cursors.set(page + 1, result.nextCursor)
|
||||
page += 1
|
||||
}
|
||||
|
||||
result = await fetchPage({
|
||||
...(query as Query),
|
||||
pageSize,
|
||||
cursor: cursors.get(page),
|
||||
})
|
||||
if (result.total != null)
|
||||
total = result.total
|
||||
if (result.nextCursor)
|
||||
cursors.set(page + 1, result.nextCursor)
|
||||
else
|
||||
cursors.delete(page + 1)
|
||||
|
||||
return { items: result.items, currentPage: page, pageSize, total }
|
||||
}
|
||||
|
||||
return { load }
|
||||
}
|
||||
|
||||
export function getUsageRecordDetail(data: UsageDetailQuery) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getDashboardSummary, getDashboardTrend } from '@/api'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import { formatCompactNumber, formatInteger } from '@/utils/number'
|
||||
import { normalizeUsageRecord } from '@/views/usage/utils/records'
|
||||
|
||||
export function useDashboard() {
|
||||
const activeTrendKind = shallowRef(normalizeDashboardTrendKind('usage'))
|
||||
@@ -215,7 +214,7 @@ export function dashboardSnapshotView(summary: DashboardSummary | null) {
|
||||
healthTimeline: summary?.healthTimeline ?? emptyHealthTimeline,
|
||||
accountUsage: (summary?.accountUsage ?? []).map(accountUsageItem),
|
||||
wireProfiles: summary?.wireProfiles ?? [],
|
||||
usageRecords: (summary?.usageRecords ?? []).map(normalizeUsageRecord),
|
||||
usageRecords: summary?.usageRecords ?? [],
|
||||
poolSummary: summary?.poolSummary ?? null,
|
||||
capacityInfo: summary?.capacityInfo ?? emptyCapacityInfo,
|
||||
rotationStrategy: summary?.rotationStrategy ?? null,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
DashboardSummaryResponse,
|
||||
DashboardTrendPoint,
|
||||
} from '@/api/modules/dashboard'
|
||||
import type { UsageRecord } from '@/api/modules/usage'
|
||||
import type { UsageListRecord } from '@/api/modules/usage'
|
||||
|
||||
interface PreviewUsageRecordOptions {
|
||||
id: string
|
||||
@@ -104,16 +104,13 @@ function previewHealthStatus(index: number): DashboardHealthStatus {
|
||||
return 'stable'
|
||||
}
|
||||
|
||||
function previewUsageRecord(options: PreviewUsageRecordOptions): UsageRecord {
|
||||
function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord {
|
||||
const totalTokens = options.inputTokens + options.outputTokens
|
||||
const reasoningTokens = Math.round(options.outputTokens * 0.32)
|
||||
const stream = options.clientTransport !== 'http_json'
|
||||
|
||||
return {
|
||||
id: options.id,
|
||||
requestId: `req_${options.id}`,
|
||||
clientApiKeyId: 'preview-client-key',
|
||||
kind: 'request.completed',
|
||||
provider: options.provider,
|
||||
authenticationKind: options.authenticationKind,
|
||||
accountId: `account_${options.id}`,
|
||||
@@ -124,51 +121,11 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageRecord {
|
||||
requestedModel: options.model,
|
||||
upstreamModel: options.model,
|
||||
serviceTier: options.provider === 'openai' ? 'priority' : 'standard',
|
||||
statusCode: 200,
|
||||
clientTransport: options.clientTransport,
|
||||
upstreamTransport: options.upstreamTransport,
|
||||
protocol: stream ? 'responses' : 'chat_completions',
|
||||
httpVersion: options.upstreamTransport === 'websocket' ? null : 'HTTP/2',
|
||||
clientStatusCode: options.clientTransport === 'websocket' ? null : 200,
|
||||
upstreamStatusCode: options.upstreamTransport === 'websocket' ? null : 200,
|
||||
websocketPool: options.upstreamTransport === 'websocket' ? { kind: 'warm' } : null,
|
||||
imageGenerationRequested: false,
|
||||
imageGenerationSucceeded: null,
|
||||
latencyDetails: {
|
||||
transportDecisionWaitMs: 4,
|
||||
upstreamHeadersMs: Math.max(24, options.firstTokenMs - 36),
|
||||
firstEventMs: options.firstTokenMs,
|
||||
firstReasoningMs: options.firstTokenMs + 18,
|
||||
firstTextMs: options.firstTokenMs + 64,
|
||||
firstTokenMs: options.firstTokenMs,
|
||||
openaiProcessingMs: options.provider === 'openai' ? options.latencyMs - 72 : undefined,
|
||||
},
|
||||
attemptIndex: 0,
|
||||
attemptCount: 1,
|
||||
responseId: `resp_${options.id}`,
|
||||
upstreamRequestId: `up_${options.id}`,
|
||||
latencyMs: options.latencyMs,
|
||||
firstTokenMs: options.firstTokenMs,
|
||||
inputTokens: options.inputTokens,
|
||||
outputTokens: options.outputTokens,
|
||||
cachedTokens: options.cachedTokens,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens,
|
||||
imageInputTokens: 0,
|
||||
imageOutputTokens: 0,
|
||||
message: '请求成功',
|
||||
metadata: {
|
||||
stream,
|
||||
apiKind: stream ? 'responses' : 'chat',
|
||||
},
|
||||
createdAt: options.createdAt,
|
||||
createdAtDisplay: options.createdAtDisplay,
|
||||
clientIp: options.clientIp,
|
||||
userAgent: options.userAgent,
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
reasoningPreset: options.reasoningEffort,
|
||||
compact: options.compact ?? false,
|
||||
requestKind: stream ? 'responses' : 'chat',
|
||||
subagentKind: null,
|
||||
tokenDetails: {
|
||||
inputTokens: options.inputTokens,
|
||||
@@ -202,12 +159,21 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageRecord {
|
||||
serviceTierDisplay: options.provider === 'openai' ? 'Fast' : 'Standard',
|
||||
multiplierDisplay: '1.0x',
|
||||
},
|
||||
costs: [{ currency: 'USD', estimatedAmount: options.estimatedCost }],
|
||||
costCoverage: { known: 1, partial: 0, unknown: 0, notBillable: 0 },
|
||||
latencyDetails: {
|
||||
transportDecisionWaitMs: 4,
|
||||
upstreamHeadersMs: Math.max(24, options.firstTokenMs - 36),
|
||||
firstEventMs: options.firstTokenMs,
|
||||
firstReasoningMs: options.firstTokenMs + 18,
|
||||
firstTextMs: options.firstTokenMs + 64,
|
||||
firstTokenMs: options.firstTokenMs,
|
||||
openaiProcessingMs: options.provider === 'openai' ? options.latencyMs - 72 : undefined,
|
||||
},
|
||||
firstTokenLatencyMs: options.firstTokenMs,
|
||||
firstTokenLatencyMsDisplay: `${options.firstTokenMs} ms`,
|
||||
latencyMsDisplay: `${options.latencyMs} ms`,
|
||||
logicalOutcome: 'success',
|
||||
latencyMs: options.latencyMs,
|
||||
createdAt: options.createdAt,
|
||||
createdAtDisplay: options.createdAtDisplay,
|
||||
clientIp: options.clientIp,
|
||||
userAgent: options.userAgent,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { getOpsErrors } from '@/api'
|
||||
import type { OpsError } from '@/api'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
@@ -9,7 +9,7 @@ import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
|
||||
import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
record: Awaited<ReturnType<typeof getOpsErrors>>['items'][number] | null
|
||||
record: OpsError | null
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
@@ -46,7 +46,7 @@ function latencyDisplay(value: unknown) {
|
||||
return typeof value === 'number' ? `${value} ms` : '—'
|
||||
}
|
||||
|
||||
function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][number] | null) {
|
||||
function providerKindLabel(record: OpsError | null) {
|
||||
if (!record?.provider)
|
||||
return '—'
|
||||
return record.authenticationKind
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UsageTimeRangeParams } from '../composables/useUsageTimeRange'
|
||||
import type { getOpsErrors } from '@/api'
|
||||
import type { OpsError } from '@/api'
|
||||
|
||||
import { Eye, RefreshCw, Search } from '@lucide/vue'
|
||||
import { shallowRef, toRef } from 'vue'
|
||||
@@ -31,21 +31,19 @@ const {
|
||||
refresh,
|
||||
} = useOpsErrorsTable(toRef(props, 'timeRangeParams'))
|
||||
|
||||
const selectedRecord = shallowRef<Awaited<ReturnType<typeof getOpsErrors>>['items'][number] | null>(
|
||||
null,
|
||||
)
|
||||
const selectedRecord = shallowRef<OpsError | null>(null)
|
||||
const detailOpen = shallowRef(false)
|
||||
|
||||
function showDetail(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][number]) {
|
||||
function showDetail(record: OpsError) {
|
||||
selectedRecord.value = record
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
function failureClassText(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][number]) {
|
||||
function failureClassText(record: OpsError) {
|
||||
return presentOpsError(record).failureClassLabel
|
||||
}
|
||||
|
||||
function errorSummary(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][number]) {
|
||||
function errorSummary(record: OpsError) {
|
||||
return presentOpsError(record).summary
|
||||
}
|
||||
</script>
|
||||
@@ -61,7 +59,7 @@ function errorSummary(record: Awaited<ReturnType<typeof getOpsErrors>>['items'][
|
||||
<div class="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2 lg:flex lg:items-center lg:gap-3">
|
||||
<BaseInput
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索消息或精确请求 ID"
|
||||
placeholder="事件、请求、Key 或账号 ID 前缀"
|
||||
class="min-w-0 sm:col-span-2 lg:min-w-64 lg:flex-1 lg:max-w-96"
|
||||
>
|
||||
<template #prefix>
|
||||
|
||||
@@ -19,7 +19,7 @@ const search = defineModel<string>('search', { required: true })
|
||||
<template>
|
||||
<div class="flex w-full items-center gap-3" role="group" aria-label="使用记录筛选与操作">
|
||||
<div class="min-w-0 flex-1 sm:w-96 sm:flex-none">
|
||||
<BaseInput v-model="search" placeholder="搜索请求 ID、端点、模型或消息" class="w-full">
|
||||
<BaseInput v-model="search" placeholder="请求、Key、账号或模型 ID 前缀" class="w-full">
|
||||
<template #prefix>
|
||||
<Search class="size-4.5 text-cp-text-tertiary" />
|
||||
</template>
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { UsageTimeRangeParams } from './useUsageTimeRange'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
import { getOpsErrors } from '@/api'
|
||||
import { createOpsErrorsPager } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { usePagedQuery } from '@/composables/usePagedQuery'
|
||||
import { useStablePagedQuery } from '@/composables/useStablePagedQuery'
|
||||
import { errorMessage, withMinimumDuration } from '@/utils/async'
|
||||
|
||||
export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>) {
|
||||
@@ -13,11 +13,12 @@ export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangePa
|
||||
const searchQuery = shallowRef('')
|
||||
const failureClass = shallowRef('')
|
||||
const route = shallowRef('')
|
||||
const pager = createOpsErrorsPager()
|
||||
|
||||
const query = usePagedQuery({
|
||||
const query = useStablePagedQuery({
|
||||
initialPageSize: 10,
|
||||
load: ({ page, pageSize }) => getOpsErrors({
|
||||
page,
|
||||
load: ({ currentPage, pageSize }) => pager.load({
|
||||
currentPage,
|
||||
pageSize,
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
failureClass: failureClass.value.trim() || undefined,
|
||||
@@ -30,20 +31,18 @@ export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangePa
|
||||
query.loading.value = true
|
||||
|
||||
const pagination = computed(() => ({
|
||||
page: query.page.value,
|
||||
currentPage: query.currentPage.value,
|
||||
pageSize: query.pageSize.value,
|
||||
total: query.total.value,
|
||||
}))
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
query.page.value = nextPage
|
||||
void query.execute()
|
||||
void query.execute(nextPage)
|
||||
}
|
||||
|
||||
function handlePageSizeChange(nextPageSize: number) {
|
||||
query.pageSize.value = nextPageSize
|
||||
query.page.value = 1
|
||||
void query.execute()
|
||||
void query.reloadFromStart()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
@@ -51,7 +50,7 @@ export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangePa
|
||||
return
|
||||
refreshing.value = true
|
||||
try {
|
||||
await withMinimumDuration(() => query.execute())
|
||||
await withMinimumDuration(() => query.execute(query.currentPage.value))
|
||||
}
|
||||
finally {
|
||||
refreshing.value = false
|
||||
@@ -61,18 +60,16 @@ export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangePa
|
||||
watchDebounced(
|
||||
[searchQuery, failureClass, route],
|
||||
() => {
|
||||
query.page.value = 1
|
||||
void query.execute()
|
||||
void query.reloadFromStart()
|
||||
},
|
||||
{ debounce: 250 },
|
||||
)
|
||||
|
||||
watch(timeRangeParams, () => {
|
||||
query.page.value = 1
|
||||
void query.execute()
|
||||
void query.reloadFromStart()
|
||||
})
|
||||
|
||||
onMounted(() => void query.execute())
|
||||
onMounted(() => void query.execute(1))
|
||||
|
||||
return {
|
||||
loading: query.loading,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UsageViewModel } from '../utils/records'
|
||||
import type { UsageDisplayRecord, UsageViewModel } from '../utils/records'
|
||||
import { shallowRef } from 'vue'
|
||||
import { getUsageRecordDetail } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
@@ -9,7 +9,7 @@ export function useUsageRecordDetail() {
|
||||
const showDetailModal = shallowRef(false)
|
||||
const selectedUsageRecord = shallowRef<UsageViewModel | null>(null)
|
||||
|
||||
async function handleViewDetail(record: UsageViewModel) {
|
||||
async function handleViewDetail(record: UsageDisplayRecord) {
|
||||
try {
|
||||
const detail = await getUsageRecordDetail({ id: record.id })
|
||||
selectedUsageRecord.value = normalizeUsageRecord(detail)
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { UsageViewModel } from '../utils/records'
|
||||
import type { UsageDisplayRecord } from '../utils/records'
|
||||
import type { UsageTimeRangeParams } from './useUsageTimeRange'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
import { computed, onMounted, onScopeDispose, shallowRef, watch } from 'vue'
|
||||
import {
|
||||
createUsageRecordsPager,
|
||||
getUsageRecordInsightsDiagnostics,
|
||||
getUsageRecordInsightsOverview,
|
||||
getUsageRecords,
|
||||
getUsageRecordSummary,
|
||||
} from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
|
||||
import { errorMessage, withMinimumDuration } from '@/utils/async'
|
||||
import { normalizeUsageRecord } from '../utils/records'
|
||||
|
||||
interface UseUsageRecordsTableOptions {
|
||||
timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>
|
||||
@@ -28,13 +26,20 @@ interface UsageLoadOptions {
|
||||
background?: boolean
|
||||
}
|
||||
|
||||
interface UsageTableParams {
|
||||
startTime: string
|
||||
endTime: string
|
||||
provider?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
const loading = shallowRef(true)
|
||||
const analyticsLoading = shallowRef(true)
|
||||
const records = shallowRef<UsageViewModel[]>([])
|
||||
const records = shallowRef<UsageDisplayRecord[]>([])
|
||||
const summary = shallowRef(emptySummary())
|
||||
const insights = shallowRef(emptyInsights())
|
||||
const page = shallowRef(1)
|
||||
const currentPage = shallowRef(1)
|
||||
const pageSize = shallowRef(10)
|
||||
const totalRecords = shallowRef(0)
|
||||
const searchQuery = shallowRef('')
|
||||
@@ -44,6 +49,7 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
})
|
||||
const refreshingList = shallowRef(false)
|
||||
const diagnosticDimension = shallowRef('model')
|
||||
const pager = createUsageRecordsPager()
|
||||
let loadRequestId = 0
|
||||
let diagnosticRequestId = 0
|
||||
const scopedParams = () => ({
|
||||
@@ -54,11 +60,31 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
search: searchQuery.value || undefined,
|
||||
})
|
||||
const usagePagination = computed(() => ({
|
||||
page: page.value,
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
total: totalRecords.value,
|
||||
}))
|
||||
|
||||
function resetPagination() {
|
||||
currentPage.value = 1
|
||||
totalRecords.value = 0
|
||||
}
|
||||
|
||||
async function loadTablePage(
|
||||
params: UsageTableParams,
|
||||
targetPage: number,
|
||||
requestId: number,
|
||||
) {
|
||||
const result = await pager.load({
|
||||
currentPage: targetPage,
|
||||
pageSize: pageSize.value,
|
||||
...params,
|
||||
})
|
||||
if (requestId !== loadRequestId)
|
||||
return null
|
||||
return { result, page: result.currentPage }
|
||||
}
|
||||
|
||||
async function loadUsageRecords(loadOptions: UsageLoadOptions = {}) {
|
||||
const { scope = 'all', background = false } = loadOptions
|
||||
const requestId = ++loadRequestId
|
||||
@@ -81,17 +107,15 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
|
||||
const globalParams = scopedParams()
|
||||
if (scope === 'all') {
|
||||
resetPagination()
|
||||
tableTimeRangeParams.value = { ...globalParams }
|
||||
}
|
||||
const tableParams = {
|
||||
...tableTimeRangeParams.value,
|
||||
...filterParams(),
|
||||
}
|
||||
const resultPromise = getUsageRecords({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
...tableParams,
|
||||
})
|
||||
const requestedPage = currentPage.value
|
||||
const resultPromise = loadTablePage(tableParams, requestedPage, requestId)
|
||||
const analyticsPromise
|
||||
= scope === 'all'
|
||||
? loadUsageAnalytics(globalParams)
|
||||
@@ -99,11 +123,12 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
summary: summary.value,
|
||||
insights: insights.value,
|
||||
})
|
||||
const [result, nextAnalytics] = await Promise.all([resultPromise, analyticsPromise])
|
||||
if (requestId !== loadRequestId)
|
||||
const [tableResult, nextAnalytics] = await Promise.all([resultPromise, analyticsPromise])
|
||||
if (requestId !== loadRequestId || tableResult == null)
|
||||
return
|
||||
const { result, page: resultPage } = tableResult
|
||||
|
||||
records.value = result.items.map(normalizeUsageRecord)
|
||||
records.value = result.items
|
||||
summary.value = nextAnalytics.summary
|
||||
insights.value = {
|
||||
...nextAnalytics.insights,
|
||||
@@ -112,14 +137,8 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
? nextAnalytics.insights.diagnostics
|
||||
: insights.value.diagnostics,
|
||||
}
|
||||
pageSize.value = result.page.pageSize
|
||||
totalRecords.value = result.page.total
|
||||
page.value = result.page.page
|
||||
|
||||
if (records.value.length === 0 && totalRecords.value > 0 && page.value > 1) {
|
||||
page.value = clamp(result.page.totalPages, 1, Number.POSITIVE_INFINITY)
|
||||
await loadUsageRecords({ scope, background })
|
||||
}
|
||||
totalRecords.value = result.total
|
||||
currentPage.value = resultPage
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (requestId !== loadRequestId)
|
||||
@@ -180,6 +199,7 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
refreshingList.value = true
|
||||
try {
|
||||
tableTimeRangeParams.value = options.latestTimeRangeParams()
|
||||
resetPagination()
|
||||
await withMinimumDuration(() => loadUsageRecords({ scope: 'table' }))
|
||||
}
|
||||
finally {
|
||||
@@ -188,13 +208,13 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
page.value = nextPage
|
||||
currentPage.value = nextPage
|
||||
void loadUsageRecords({ scope: 'table' })
|
||||
}
|
||||
|
||||
function handlePageSizeChange(nextPageSize: number) {
|
||||
pageSize.value = nextPageSize
|
||||
page.value = 1
|
||||
resetPagination()
|
||||
void loadUsageRecords({ scope: 'table' })
|
||||
}
|
||||
|
||||
@@ -207,21 +227,25 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
|
||||
})
|
||||
|
||||
watch(providerQuery, () => {
|
||||
page.value = 1
|
||||
void loadUsageRecords({ background: true })
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
searchQuery,
|
||||
() => {
|
||||
page.value = 1
|
||||
resetPagination()
|
||||
void loadUsageRecords({ scope: 'table' })
|
||||
},
|
||||
{ debounce: 250 },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
loadRequestId += 1
|
||||
diagnosticRequestId += 1
|
||||
})
|
||||
|
||||
return {
|
||||
page,
|
||||
currentPage,
|
||||
pageSize,
|
||||
searchQuery,
|
||||
providerQuery,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { UsageDisplayRecord } from './utils/records'
|
||||
import type { getOpsErrors } from '@/api'
|
||||
import type { OpsError } from '@/api'
|
||||
import { defineTableColumns } from '@/components/base/BaseTable/columns'
|
||||
import { formatProvider } from './utils/format'
|
||||
|
||||
type OpsErrorRow = Awaited<ReturnType<typeof getOpsErrors>>['items'][number]
|
||||
type OpsErrorRow = OpsError
|
||||
|
||||
export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ const { timeRange, timeRangeParams, refreshTimeRangeEnd, latestTimeRangeParams }
|
||||
= useUsageTimeRange()
|
||||
|
||||
const {
|
||||
page,
|
||||
currentPage,
|
||||
searchQuery,
|
||||
providerQuery,
|
||||
usagePagination,
|
||||
@@ -53,7 +53,7 @@ const { showDetailModal, selectedUsageRecord, handleViewDetail } = useUsageRecor
|
||||
|
||||
watch(timeRange, () => {
|
||||
refreshTimeRangeEnd()
|
||||
page.value = 1
|
||||
currentPage.value = 1
|
||||
void loadUsageRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// Usage 记录的单一规范化 view model 消费层。
|
||||
//
|
||||
// 组件只消费 `UsageDisplayRecord`(即 `UsageViewModel`)。
|
||||
// Usage 列表与详情使用独立读模型;共享展示函数只依赖两者的公共字段。
|
||||
|
||||
import type {
|
||||
UsageAttempt,
|
||||
@@ -8,7 +6,7 @@ import type {
|
||||
UsageCost,
|
||||
UsageCostCoverage,
|
||||
UsageLatencyDetails,
|
||||
UsageRecord,
|
||||
UsageListRecord,
|
||||
UsageRecordDetail,
|
||||
UsageTokenDetails,
|
||||
} from '@/api'
|
||||
@@ -80,8 +78,8 @@ export interface UsageViewModel {
|
||||
attemptsComplete?: boolean
|
||||
}
|
||||
|
||||
/** 将现行 Usage API 记录收口为组件消费的单一形状。 */
|
||||
export function normalizeUsageRecord(record: UsageRecord | UsageRecordDetail): UsageViewModel {
|
||||
/** 将 Usage 详情 API 记录收口为详情组件消费的形状。 */
|
||||
export function normalizeUsageRecord(record: UsageRecordDetail): UsageViewModel {
|
||||
const metadata = record.metadata
|
||||
|
||||
return {
|
||||
@@ -130,7 +128,7 @@ export function normalizeUsageRecord(record: UsageRecord | UsageRecordDetail): U
|
||||
userAgent: record.userAgent,
|
||||
reasoningEffort: record.reasoningEffort,
|
||||
reasoningPreset: record.reasoningPreset,
|
||||
compact: record.compact ?? false,
|
||||
compact: record.compact === true,
|
||||
requestKind: record.requestKind,
|
||||
subagentKind: record.subagentKind,
|
||||
tokenDetails: record.tokenDetails,
|
||||
@@ -143,12 +141,14 @@ export function normalizeUsageRecord(record: UsageRecord | UsageRecordDetail): U
|
||||
providerMetadata: metadata,
|
||||
requestBody: metadata.requestBody,
|
||||
responseBody: metadata.responseBody,
|
||||
attempts: 'attempts' in record ? record.attempts : undefined,
|
||||
attemptsComplete: 'attemptsComplete' in record ? record.attemptsComplete : undefined,
|
||||
attempts: record.attempts,
|
||||
attemptsComplete: record.attemptsComplete,
|
||||
}
|
||||
}
|
||||
|
||||
export type UsageDisplayRecord = UsageViewModel
|
||||
export type UsageDisplayRecord = UsageListRecord
|
||||
|
||||
type UsageCommonRecord = UsageDisplayRecord | UsageViewModel
|
||||
|
||||
export function usageTransportType(transport?: string | null) {
|
||||
if (transport === 'websocket')
|
||||
@@ -170,38 +170,38 @@ export function usageTransportTypeClass(transport?: string | null) {
|
||||
return 'bg-cp-fill-tertiary text-cp-text-secondary'
|
||||
}
|
||||
|
||||
export function usageAccountText(record: UsageDisplayRecord) {
|
||||
export function usageAccountText(record: UsageCommonRecord) {
|
||||
return record.accountEmail || record.accountName || record.accountId || '—'
|
||||
}
|
||||
|
||||
export function usageAuthenticationKind(record: UsageDisplayRecord) {
|
||||
export function usageAuthenticationKind(record: UsageCommonRecord) {
|
||||
return typeof record.authenticationKind === 'string' ? record.authenticationKind : null
|
||||
}
|
||||
|
||||
export function usageClientIp(record: UsageDisplayRecord) {
|
||||
export function usageClientIp(record: UsageCommonRecord) {
|
||||
return record.clientIp || '—'
|
||||
}
|
||||
|
||||
export function usageUserAgent(record: UsageDisplayRecord) {
|
||||
export function usageUserAgent(record: UsageCommonRecord) {
|
||||
return record.userAgent || '—'
|
||||
}
|
||||
|
||||
export function usageReasoningEffort(record: UsageDisplayRecord) {
|
||||
export function usageReasoningEffort(record: UsageCommonRecord) {
|
||||
const reasoningEffort = record.reasoningEffort || '—'
|
||||
if (usageIsSubagent(record))
|
||||
return reasoningEffort
|
||||
return record.reasoningPreset || reasoningEffort
|
||||
}
|
||||
|
||||
export function usageIsSubagent(record: UsageDisplayRecord) {
|
||||
export function usageIsSubagent(record: UsageCommonRecord) {
|
||||
return Boolean(record.subagentKind)
|
||||
}
|
||||
|
||||
export function usageIsCompact(record: UsageDisplayRecord) {
|
||||
export function usageIsCompact(record: UsageCommonRecord) {
|
||||
return record.compact === true
|
||||
}
|
||||
|
||||
export function usageModelDisplay(record: UsageDisplayRecord) {
|
||||
export function usageModelDisplay(record: UsageCommonRecord) {
|
||||
const requestedModel = record.requestedModel || ''
|
||||
const upstreamModel = record.upstreamModel || ''
|
||||
const storedModel = record.model || ''
|
||||
@@ -216,11 +216,11 @@ export function usageModelDisplay(record: UsageDisplayRecord) {
|
||||
return { primary, secondary }
|
||||
}
|
||||
|
||||
export function usageTokenDetails(record: UsageDisplayRecord) {
|
||||
export function usageTokenDetails(record: UsageCommonRecord) {
|
||||
return record.tokenDetails
|
||||
}
|
||||
|
||||
export function usageLatencyDetails(record: UsageDisplayRecord) {
|
||||
export function usageLatencyDetails(record: UsageCommonRecord) {
|
||||
const latencyDetails = record.latencyDetails
|
||||
const firstTokenMs = durationValue(
|
||||
record.firstTokenLatencyMs ?? latencyDetails?.firstTokenMs,
|
||||
@@ -287,15 +287,15 @@ export function usageLatencyDetails(record: UsageDisplayRecord) {
|
||||
}
|
||||
}
|
||||
|
||||
export function usageBilling(record: UsageDisplayRecord) {
|
||||
export function usageBilling(record: UsageCommonRecord) {
|
||||
return record.billing
|
||||
}
|
||||
|
||||
export function usageBillingText(record: UsageDisplayRecord) {
|
||||
export function usageBillingText(record: UsageCommonRecord) {
|
||||
return usageBilling(record)?.totalAmountDisplay || '—'
|
||||
}
|
||||
|
||||
export function visibleRequestText(record: UsageDisplayRecord) {
|
||||
export function visibleRequestText(record: UsageViewModel) {
|
||||
const body = record.requestBody
|
||||
if (!body)
|
||||
return ''
|
||||
@@ -303,7 +303,7 @@ export function visibleRequestText(record: UsageDisplayRecord) {
|
||||
return extractInputText(body) || JSON.stringify(body, null, 2)
|
||||
}
|
||||
|
||||
export function visibleResponseText(record: UsageDisplayRecord) {
|
||||
export function visibleResponseText(record: UsageViewModel) {
|
||||
const body = record.responseBody
|
||||
if (!body)
|
||||
return ''
|
||||
|
||||
Reference in New Issue
Block a user