mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 13:10:21 +08:00
Track server clock offset for usage data
This commit is contained in:
+12
-3
@@ -5,6 +5,11 @@ import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
import type { UserSession } from '@/types/session'
|
||||
import type { FeatureSettingsMap } from '@/utils/featureSettings'
|
||||
import {
|
||||
beginServerTimingSample,
|
||||
withServerTiming,
|
||||
type ServerTimedPayload,
|
||||
} from './serverTiming'
|
||||
|
||||
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
@@ -142,7 +147,8 @@ export interface ApiFormatSummary {
|
||||
}
|
||||
|
||||
// 使用统计响应接口
|
||||
export interface UsageResponse {
|
||||
export interface UsageResponse extends ServerTimedPayload {
|
||||
server_now_unix_ms?: number
|
||||
total_requests: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
@@ -321,12 +327,14 @@ export const meApi = {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<UsageResponse> {
|
||||
const clientSendUnixMs = beginServerTimingSample()
|
||||
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
||||
return response.data
|
||||
return withServerTiming(response.data, clientSendUnixMs)
|
||||
},
|
||||
|
||||
// 获取活跃请求状态(用于轮询更新)
|
||||
async getActiveRequests(ids?: string): Promise<{
|
||||
server_timing?: ServerTimedPayload['server_timing']
|
||||
requests: Array<{
|
||||
id: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
@@ -358,8 +366,9 @@ export const meApi = {
|
||||
}>
|
||||
}> {
|
||||
const params = ids ? { ids } : {}
|
||||
const clientSendUnixMs = beginServerTimingSample()
|
||||
const response = await apiClient.get('/api/users/me/usage/active', { params })
|
||||
return response.data
|
||||
return withServerTiming(response.data, clientSendUnixMs)
|
||||
},
|
||||
|
||||
// 获取可用的提供商
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface ServerTimingMetadata {
|
||||
server_now_unix_ms: number
|
||||
client_send_unix_ms: number
|
||||
client_receive_unix_ms: number
|
||||
}
|
||||
|
||||
export interface ServerTimedPayload {
|
||||
server_timing?: ServerTimingMetadata
|
||||
}
|
||||
|
||||
export function beginServerTimingSample(): number {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
export function readServerNowUnixMs(payload: unknown): number | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const value = (payload as { server_now_unix_ms?: unknown }).server_now_unix_ms
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export function buildServerTimingMetadata(
|
||||
payload: unknown,
|
||||
clientSendUnixMs: number,
|
||||
clientReceiveUnixMs = Date.now()
|
||||
): ServerTimingMetadata | undefined {
|
||||
const serverNowUnixMs = readServerNowUnixMs(payload)
|
||||
if (serverNowUnixMs == null) return undefined
|
||||
if (!Number.isFinite(clientSendUnixMs) || !Number.isFinite(clientReceiveUnixMs)) return undefined
|
||||
|
||||
return {
|
||||
server_now_unix_ms: serverNowUnixMs,
|
||||
client_send_unix_ms: clientSendUnixMs,
|
||||
client_receive_unix_ms: clientReceiveUnixMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function withServerTiming<T extends object>(payload: T, clientSendUnixMs: number): T & ServerTimedPayload {
|
||||
const serverTiming = buildServerTimingMetadata(payload, clientSendUnixMs)
|
||||
if (!serverTiming) return payload
|
||||
return {
|
||||
...payload,
|
||||
server_timing: serverTiming,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import apiClient from './client'
|
||||
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { ActivityHeatmap } from '@/types/activity'
|
||||
import type { ImageProgress } from './requestTrace'
|
||||
import {
|
||||
beginServerTimingSample,
|
||||
withServerTiming,
|
||||
type ServerTimedPayload,
|
||||
} from './serverTiming'
|
||||
|
||||
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
|
||||
const USAGE_ANALYTICS_CACHE_TTL_MS = 30 * 1000
|
||||
@@ -127,8 +132,9 @@ export interface UsageRequestOptions {
|
||||
skipCache?: boolean
|
||||
}
|
||||
|
||||
type UsageListResponse = {
|
||||
type UsageListResponse = ServerTimedPayload & {
|
||||
records?: unknown
|
||||
server_now_unix_ms?: unknown
|
||||
pagination?: {
|
||||
total?: unknown
|
||||
limit?: unknown
|
||||
@@ -199,6 +205,7 @@ function normalizeUsageRecordPage(
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
server_timing?: ServerTimedPayload['server_timing']
|
||||
} {
|
||||
const records = assertUsageRecords(payload.records)
|
||||
const pagination = payload.pagination
|
||||
@@ -220,6 +227,7 @@ function normalizeUsageRecordPage(
|
||||
total,
|
||||
page: resolvedPage,
|
||||
page_size: limit,
|
||||
...(payload.server_timing ? { server_timing: payload.server_timing } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,10 +374,12 @@ export const usageApi = {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
server_timing?: ServerTimedPayload['server_timing']
|
||||
}> {
|
||||
const { params, pagination } = buildCurrentUserUsageParams(filters)
|
||||
const clientSendUnixMs = beginServerTimingSample()
|
||||
const response = await apiClient.get<UsageListResponse>('/api/users/me/usage', { params })
|
||||
return normalizeUsageRecordPage(response.data, pagination)
|
||||
return normalizeUsageRecordPage(withServerTiming(response.data, clientSendUnixMs), pagination)
|
||||
},
|
||||
|
||||
async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> {
|
||||
@@ -444,17 +454,21 @@ export const usageApi = {
|
||||
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
|
||||
records: UsageRecord[]
|
||||
stats: UsageStats
|
||||
server_timing?: ServerTimedPayload['server_timing']
|
||||
}> {
|
||||
const statsParams = buildAdminUsageStatsParams(userId, filters)
|
||||
const { params: recordParams } = buildAdminUsageRecordParams(userId, filters)
|
||||
const clientSendUnixMs = beginServerTimingSample()
|
||||
const [statsResponse, recordsResponse] = await Promise.all([
|
||||
apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams }),
|
||||
apiClient.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams }),
|
||||
])
|
||||
const recordsPayload = withServerTiming(recordsResponse.data, clientSendUnixMs)
|
||||
|
||||
return {
|
||||
records: assertUsageRecords(recordsResponse.data.records),
|
||||
records: assertUsageRecords(recordsPayload.records),
|
||||
stats: statsResponse.data,
|
||||
...(recordsPayload.server_timing ? { server_timing: recordsPayload.server_timing } : {}),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -479,11 +493,13 @@ export const usageApi = {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
server_timing?: ServerTimedPayload['server_timing']
|
||||
}> {
|
||||
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
|
||||
return dedupedRequest(key, async () => {
|
||||
const clientSendUnixMs = beginServerTimingSample()
|
||||
const response = await apiClient.get('/api/admin/usage/records', { params })
|
||||
return response.data
|
||||
return withServerTiming(response.data, clientSendUnixMs)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -495,6 +511,7 @@ export const usageApi = {
|
||||
ids?: string[],
|
||||
timeRange?: Pick<UsageFilters, 'start_date' | 'end_date' | 'preset' | 'timezone' | 'tz_offset_minutes'>
|
||||
): Promise<{
|
||||
server_timing?: ServerTimedPayload['server_timing']
|
||||
requests: Array<{
|
||||
id: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
@@ -548,8 +565,9 @@ export const usageApi = {
|
||||
if (typeof timeRange?.tz_offset_minutes === 'number') {
|
||||
params.tz_offset_minutes = timeRange.tz_offset_minutes
|
||||
}
|
||||
const clientSendUnixMs = beginServerTimingSample()
|
||||
const response = await apiClient.get('/api/admin/usage/active', { params })
|
||||
return response.data
|
||||
return withServerTiming(response.data, clientSendUnixMs)
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calculateServerClockOffsetMs, useServerClock } from '../useServerClock'
|
||||
|
||||
describe('useServerClock', () => {
|
||||
it('calculates offset from the request midpoint', () => {
|
||||
const offset = calculateServerClockOffsetMs({
|
||||
server_now_unix_ms: 10_500,
|
||||
client_send_unix_ms: 20_000,
|
||||
client_receive_unix_ms: 20_200,
|
||||
})
|
||||
|
||||
expect(offset).toBe(-9_600)
|
||||
})
|
||||
|
||||
it('ignores missing or invalid timing samples', () => {
|
||||
expect(calculateServerClockOffsetMs(undefined)).toBeNull()
|
||||
expect(calculateServerClockOffsetMs({
|
||||
server_now_unix_ms: Number.NaN,
|
||||
client_send_unix_ms: 20_000,
|
||||
client_receive_unix_ms: 20_200,
|
||||
})).toBeNull()
|
||||
expect(calculateServerClockOffsetMs({
|
||||
server_now_unix_ms: 10_500,
|
||||
client_send_unix_ms: 20_200,
|
||||
client_receive_unix_ms: 20_000,
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the previous offset when a response has no server timing', () => {
|
||||
const clock = useServerClock()
|
||||
|
||||
clock.updateServerClockOffset({
|
||||
server_now_unix_ms: 10_500,
|
||||
client_send_unix_ms: 20_000,
|
||||
client_receive_unix_ms: 20_200,
|
||||
})
|
||||
clock.updateServerClockOffset(undefined)
|
||||
|
||||
expect(clock.hasServerClockOffset.value).toBe(true)
|
||||
expect(clock.serverClockOffsetMs.value).toBe(-9_600)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ServerTimingMetadata } from '@/api/serverTiming'
|
||||
|
||||
export function calculateServerClockOffsetMs(timing: ServerTimingMetadata | null | undefined): number | null {
|
||||
if (!timing) return null
|
||||
const { server_now_unix_ms: serverNowUnixMs, client_send_unix_ms: clientSendUnixMs, client_receive_unix_ms: clientReceiveUnixMs } = timing
|
||||
|
||||
if (!Number.isFinite(serverNowUnixMs) || !Number.isFinite(clientSendUnixMs) || !Number.isFinite(clientReceiveUnixMs)) {
|
||||
return null
|
||||
}
|
||||
if (clientReceiveUnixMs < clientSendUnixMs) {
|
||||
return null
|
||||
}
|
||||
|
||||
const clientMidpointUnixMs = clientSendUnixMs + ((clientReceiveUnixMs - clientSendUnixMs) / 2)
|
||||
return serverNowUnixMs - clientMidpointUnixMs
|
||||
}
|
||||
|
||||
export function useServerClock() {
|
||||
const serverClockOffsetMs = ref(0)
|
||||
const hasServerClockOffset = ref(false)
|
||||
|
||||
function updateServerClockOffset(timing: ServerTimingMetadata | null | undefined): void {
|
||||
const offsetMs = calculateServerClockOffsetMs(timing)
|
||||
if (offsetMs == null) return
|
||||
|
||||
serverClockOffsetMs.value = offsetMs
|
||||
hasServerClockOffset.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
serverClockOffsetMs,
|
||||
hasServerClockOffset,
|
||||
updateServerClockOffset,
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { createDefaultStats } from '../types'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getErrorStatus } from '@/types/api-error'
|
||||
import { isUsageProviderVisible, normalizeUsageProviderStats } from '../utils/providerStats'
|
||||
import { useServerClock } from './useServerClock'
|
||||
|
||||
export interface UseUsageDataOptions {
|
||||
isAdminPage: Ref<boolean>
|
||||
@@ -65,6 +66,11 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
||||
const availableModels = ref<string[]>([])
|
||||
const availableProviders = ref<string[]>([])
|
||||
const {
|
||||
serverClockOffsetMs,
|
||||
hasServerClockOffset,
|
||||
updateServerClockOffset,
|
||||
} = useServerClock()
|
||||
|
||||
// 增强的模型统计(包含效率分析)
|
||||
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
|
||||
@@ -213,6 +219,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return false
|
||||
}
|
||||
updateServerClockOffset(userData.server_timing)
|
||||
|
||||
stats.value = {
|
||||
total_requests: userData.total_requests || 0,
|
||||
@@ -366,6 +373,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
updateServerClockOffset(response.server_timing)
|
||||
const nextRecords = (response.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = response.total || 0
|
||||
@@ -375,6 +383,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
updateServerClockOffset(userData.server_timing)
|
||||
const nextRecords = (userData.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = userData.pagination?.total || currentRecords.value.length
|
||||
@@ -557,6 +566,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
apiFormatStats,
|
||||
currentRecords,
|
||||
totalRecords,
|
||||
serverClockOffsetMs,
|
||||
hasServerClockOffset,
|
||||
|
||||
// 筛选选项
|
||||
availableModels,
|
||||
@@ -568,6 +579,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
// 方法
|
||||
loadStats,
|
||||
loadRecords,
|
||||
refreshData
|
||||
refreshData,
|
||||
updateServerClockOffset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +246,8 @@ const {
|
||||
availableModels,
|
||||
availableProviders,
|
||||
loadStats,
|
||||
loadRecords
|
||||
loadRecords,
|
||||
updateServerClockOffset
|
||||
} = useUsageData({ isAdminPage })
|
||||
|
||||
// 热力图状态
|
||||
@@ -445,10 +446,14 @@ const discoveredActiveRequestIds = new Set<string>()
|
||||
|
||||
async function loadActiveRequestUpdates(ids?: string[]) {
|
||||
if (isAdminPage.value) {
|
||||
return usageApi.getActiveRequests(ids, timeRange.value)
|
||||
const result = await usageApi.getActiveRequests(ids, timeRange.value)
|
||||
updateServerClockOffset(result.server_timing)
|
||||
return result
|
||||
}
|
||||
const idsParam = ids?.length ? ids.join(',') : undefined
|
||||
return meApi.getActiveRequests(idsParam)
|
||||
const result = await meApi.getActiveRequests(idsParam)
|
||||
updateServerClockOffset(result.server_timing)
|
||||
return result
|
||||
}
|
||||
|
||||
async function pollActiveRequests() {
|
||||
|
||||
Reference in New Issue
Block a user