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:
Zyy
2026-08-31 02:40:40 +08:00
co-authored by Codex
parent a2e60c1c6e
commit 2231fee768
37 changed files with 1915 additions and 1045 deletions
+2 -2
View File
@@ -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
+125 -27
View File
@@ -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,
+16 -50
View File
@@ -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,
+2 -2
View File
@@ -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>([
{
+2 -2
View File
@@ -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>
+24 -24
View File
@@ -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 ''