mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
Revert "Merge remote-tracking branch 'origin/pr/592'"
This reverts commit3504875922, reversing changes made to5c3a1aecbe.
This commit is contained in:
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SERVER_NOW_UNIX_MS_HEADER,
|
||||
buildServerTimingMetadata,
|
||||
readServerNowUnixMsFromHeaders,
|
||||
withServerTiming,
|
||||
} from '../serverTiming'
|
||||
|
||||
describe('serverTiming', () => {
|
||||
it('reads server time from response headers', () => {
|
||||
expect(readServerNowUnixMsFromHeaders({
|
||||
[SERVER_NOW_UNIX_MS_HEADER]: '1779999000123',
|
||||
})).toBe(1_779_999_000_123)
|
||||
expect(readServerNowUnixMsFromHeaders({
|
||||
'X-Aether-Server-Now-Unix-Ms': '1779999000456',
|
||||
})).toBe(1_779_999_000_456)
|
||||
})
|
||||
|
||||
it('does not fall back to body fields', () => {
|
||||
const timing = buildServerTimingMetadata({
|
||||
headers: {},
|
||||
data: {
|
||||
server_now_unix_ms: 1_779_999_000_123,
|
||||
},
|
||||
}, 1_000, 1_100)
|
||||
|
||||
expect(timing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds metadata with round trip duration', () => {
|
||||
const timing = buildServerTimingMetadata({
|
||||
headers: {
|
||||
[SERVER_NOW_UNIX_MS_HEADER]: '1050',
|
||||
},
|
||||
}, 1_000, 1_125)
|
||||
|
||||
expect(timing).toEqual({
|
||||
server_now_unix_ms: 1_050,
|
||||
client_send_unix_ms: 1_000,
|
||||
client_receive_unix_ms: 1_125,
|
||||
round_trip_ms: 125,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the original payload when the header is missing or invalid', () => {
|
||||
const payload = { records: [] }
|
||||
|
||||
expect(withServerTiming({ data: payload, headers: {} }, 1_000)).toBe(payload)
|
||||
expect(withServerTiming({
|
||||
data: payload,
|
||||
headers: { [SERVER_NOW_UNIX_MS_HEADER]: 'not-a-number' },
|
||||
}, 1_000)).toBe(payload)
|
||||
expect(withServerTiming({
|
||||
data: payload,
|
||||
headers: { [SERVER_NOW_UNIX_MS_HEADER]: '0' },
|
||||
}, 1_000)).toBe(payload)
|
||||
expect(withServerTiming({
|
||||
data: payload,
|
||||
headers: { [SERVER_NOW_UNIX_MS_HEADER]: '1050.5' },
|
||||
}, 1_000)).toBe(payload)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getMock, cachedRequestMock, dedupedRequestMock, buildCacheKeyMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
@@ -29,10 +29,6 @@ describe('usageApi contract alignment', () => {
|
||||
buildCacheKeyMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads current-user usage records from the Rust usage endpoint and normalizes pagination', async () => {
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
@@ -119,56 +115,6 @@ describe('usageApi contract alignment', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('captures admin user usage server timing when the records request resolves', async () => {
|
||||
let now = 1_000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
|
||||
let resolveStats: ((value: unknown) => void) | null = null
|
||||
getMock.mockImplementation((url: string) => {
|
||||
if (url === '/api/admin/usage/stats') {
|
||||
return new Promise(resolve => {
|
||||
resolveStats = resolve
|
||||
})
|
||||
}
|
||||
if (url === '/api/admin/usage/records') {
|
||||
return Promise.resolve({
|
||||
headers: {
|
||||
'x-aether-server-now-unix-ms': '10050',
|
||||
},
|
||||
data: {
|
||||
records: [{ id: 'record-3' }],
|
||||
total: 1,
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected url: ${url}`))
|
||||
})
|
||||
|
||||
const resultPromise = usageApi.getUserUsage('user-123', { page: 1, page_size: 25 })
|
||||
now = 1_100
|
||||
await Promise.resolve()
|
||||
now = 20_000
|
||||
resolveStats?.({
|
||||
data: {
|
||||
total_requests: 1,
|
||||
total_tokens: 10,
|
||||
total_cost: 0.1,
|
||||
avg_response_time: 500,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result.server_timing).toEqual({
|
||||
server_now_unix_ms: 10_050,
|
||||
client_send_unix_ms: 1_000,
|
||||
client_receive_unix_ms: 1_100,
|
||||
round_trip_ms: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an extended timeout and cache bypass option for admin analytics', async () => {
|
||||
getMock
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
+3
-11
@@ -5,11 +5,6 @@ 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
|
||||
|
||||
@@ -147,7 +142,7 @@ export interface ApiFormatSummary {
|
||||
}
|
||||
|
||||
// 使用统计响应接口
|
||||
export interface UsageResponse extends ServerTimedPayload {
|
||||
export interface UsageResponse {
|
||||
total_requests: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
@@ -326,14 +321,12 @@ export const meApi = {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<UsageResponse> {
|
||||
const clientSendUnixMs = beginServerTimingSample()
|
||||
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
||||
return withServerTiming(response, clientSendUnixMs)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取活跃请求状态(用于轮询更新)
|
||||
async getActiveRequests(ids?: string): Promise<{
|
||||
server_timing?: ServerTimedPayload['server_timing']
|
||||
requests: Array<{
|
||||
id: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
@@ -365,9 +358,8 @@ export const meApi = {
|
||||
}>
|
||||
}> {
|
||||
const params = ids ? { ids } : {}
|
||||
const clientSendUnixMs = beginServerTimingSample()
|
||||
const response = await apiClient.get('/api/users/me/usage/active', { params })
|
||||
return withServerTiming(response, clientSendUnixMs)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取可用的提供商
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
export const SERVER_NOW_UNIX_MS_HEADER = 'x-aether-server-now-unix-ms'
|
||||
|
||||
export interface ServerTimingMetadata {
|
||||
server_now_unix_ms: number
|
||||
client_send_unix_ms: number
|
||||
client_receive_unix_ms: number
|
||||
round_trip_ms: number
|
||||
}
|
||||
|
||||
export interface ServerTimedPayload {
|
||||
server_timing?: ServerTimingMetadata
|
||||
}
|
||||
|
||||
export function beginServerTimingSample(): number {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
function readHeaderValue(headers: unknown, name: string): unknown {
|
||||
if (!headers || typeof headers !== 'object') return undefined
|
||||
|
||||
const get = (headers as { get?: unknown }).get
|
||||
if (typeof get === 'function') {
|
||||
return get.call(headers, name)
|
||||
}
|
||||
|
||||
const lowerName = name.toLowerCase()
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
if (key.toLowerCase() === lowerName) return value
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function readServerNowUnixMsFromHeaders(headers: unknown): number | null {
|
||||
const value = readHeaderValue(headers, SERVER_NOW_UNIX_MS_HEADER)
|
||||
const raw = Array.isArray(value) ? value[0] : value
|
||||
const parsed = typeof raw === 'number'
|
||||
? raw
|
||||
: typeof raw === 'string'
|
||||
? Number(raw.trim())
|
||||
: Number.NaN
|
||||
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
export function buildServerTimingMetadata(
|
||||
response: Pick<AxiosResponse, 'headers'> | { headers?: unknown } | null | undefined,
|
||||
clientSendUnixMs: number,
|
||||
clientReceiveUnixMs = Date.now()
|
||||
): ServerTimingMetadata | undefined {
|
||||
const serverNowUnixMs = readServerNowUnixMsFromHeaders(response?.headers)
|
||||
if (serverNowUnixMs == null) return undefined
|
||||
if (!Number.isFinite(clientSendUnixMs) || !Number.isFinite(clientReceiveUnixMs)) return undefined
|
||||
if (clientReceiveUnixMs < clientSendUnixMs) return undefined
|
||||
|
||||
const roundTripMs = clientReceiveUnixMs - clientSendUnixMs
|
||||
|
||||
return {
|
||||
server_now_unix_ms: serverNowUnixMs,
|
||||
client_send_unix_ms: clientSendUnixMs,
|
||||
client_receive_unix_ms: clientReceiveUnixMs,
|
||||
round_trip_ms: roundTripMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function withServerTiming<T extends object>(
|
||||
response: Pick<AxiosResponse<T>, 'data' | 'headers'>,
|
||||
clientSendUnixMs: number
|
||||
): T & ServerTimedPayload {
|
||||
const serverTiming = buildServerTimingMetadata(response, clientSendUnixMs)
|
||||
if (!serverTiming) return response.data
|
||||
return {
|
||||
...response.data,
|
||||
server_timing: serverTiming,
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,6 @@ 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
|
||||
@@ -132,7 +127,7 @@ export interface UsageRequestOptions {
|
||||
skipCache?: boolean
|
||||
}
|
||||
|
||||
type UsageListResponse = ServerTimedPayload & {
|
||||
type UsageListResponse = {
|
||||
records?: unknown
|
||||
pagination?: {
|
||||
total?: unknown
|
||||
@@ -204,7 +199,6 @@ function normalizeUsageRecordPage(
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
server_timing?: ServerTimedPayload['server_timing']
|
||||
} {
|
||||
const records = assertUsageRecords(payload.records)
|
||||
const pagination = payload.pagination
|
||||
@@ -226,7 +220,6 @@ function normalizeUsageRecordPage(
|
||||
total,
|
||||
page: resolvedPage,
|
||||
page_size: limit,
|
||||
...(payload.server_timing ? { server_timing: payload.server_timing } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,12 +366,10 @@ 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(withServerTiming(response, clientSendUnixMs), pagination)
|
||||
return normalizeUsageRecordPage(response.data, pagination)
|
||||
},
|
||||
|
||||
async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> {
|
||||
@@ -453,25 +444,17 @@ 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 statsRequest = apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams })
|
||||
const recordsClientSendUnixMs = beginServerTimingSample()
|
||||
const recordsRequest = apiClient
|
||||
.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams })
|
||||
.then(response => withServerTiming(response, recordsClientSendUnixMs))
|
||||
|
||||
const [statsResponse, recordsResponse] = await Promise.all([
|
||||
statsRequest,
|
||||
recordsRequest,
|
||||
apiClient.get<UsageStats>('/api/admin/usage/stats', { params: statsParams }),
|
||||
apiClient.get<UsageListResponse>('/api/admin/usage/records', { params: recordParams }),
|
||||
])
|
||||
|
||||
return {
|
||||
records: assertUsageRecords(recordsResponse.records),
|
||||
records: assertUsageRecords(recordsResponse.data.records),
|
||||
stats: statsResponse.data,
|
||||
...(recordsResponse.server_timing ? { server_timing: recordsResponse.server_timing } : {}),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -496,13 +479,11 @@ 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 withServerTiming(response, clientSendUnixMs)
|
||||
return response.data
|
||||
})
|
||||
},
|
||||
|
||||
@@ -514,7 +495,6 @@ 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'
|
||||
@@ -568,9 +548,8 @@ 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 withServerTiming(response, clientSendUnixMs)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,25 +3,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
createdAt?: string | null
|
||||
status?: string | null
|
||||
responseTimeMs?: number | null
|
||||
displayNowMs?: number | null
|
||||
precision?: number
|
||||
}>(), {
|
||||
createdAt: null,
|
||||
status: null,
|
||||
responseTimeMs: null,
|
||||
displayNowMs: null,
|
||||
precision: 2,
|
||||
})
|
||||
|
||||
const now = ref(Date.now())
|
||||
const precision = computed(() => Math.max(0, props.precision))
|
||||
const isActive = computed(() => props.status === 'pending' || props.status === 'streaming')
|
||||
|
||||
let rafId: number | null = null
|
||||
|
||||
function parseCreatedAtMs(value: string | null | undefined): number {
|
||||
if (!value) return Number.NaN
|
||||
// 后端有时返回无时区时间,按 UTC 解析,和列表时间显示逻辑保持一致
|
||||
@@ -29,6 +30,35 @@ function parseCreatedAtMs(value: string | null | undefined): number {
|
||||
return new Date(normalized).getTime()
|
||||
}
|
||||
|
||||
function stopRaf() {
|
||||
if (rafId == null) return
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
function tick() {
|
||||
now.value = Date.now()
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function startRaf() {
|
||||
stopRaf()
|
||||
now.value = Date.now()
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
watch(isActive, (active) => {
|
||||
if (active) {
|
||||
startRaf()
|
||||
} else {
|
||||
stopRaf()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRaf()
|
||||
})
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!isActive.value) {
|
||||
if (props.responseTimeMs == null) return '-'
|
||||
@@ -40,11 +70,7 @@ const displayText = computed(() => {
|
||||
const createdAtMs = parseCreatedAtMs(props.createdAt)
|
||||
if (Number.isNaN(createdAtMs)) return '-'
|
||||
|
||||
// 活跃请求里的 response_time_ms 可能只是首字或中间值;终态才使用后端最终耗时。
|
||||
const nowMs = typeof props.displayNowMs === 'number' && Number.isFinite(props.displayNowMs)
|
||||
? props.displayNowMs
|
||||
: Date.now()
|
||||
const elapsedMs = Math.max(0, nowMs - createdAtMs)
|
||||
const elapsedMs = Math.max(0, now.value - createdAtMs)
|
||||
return `${(elapsedMs / 1000).toFixed(precision.value)}s`
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -324,7 +324,6 @@
|
||||
:created-at="record.created_at"
|
||||
:status="getDisplayStatus(record)"
|
||||
:response-time-ms="record.response_time_ms ?? null"
|
||||
:display-now-ms="displayNowMs ?? null"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
@@ -941,7 +940,6 @@
|
||||
:created-at="record.created_at"
|
||||
:status="getDisplayStatus(record)"
|
||||
:response-time-ms="record.response_time_ms ?? null"
|
||||
:display-now-ms="displayNowMs ?? null"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1116,7 +1114,6 @@ const props = defineProps<{
|
||||
pageSizeOptions: number[]
|
||||
// 自动刷新
|
||||
autoRefresh: boolean
|
||||
displayNowMs?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, type App } from 'vue'
|
||||
import ElapsedTimeText from '../ElapsedTimeText.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountElapsedTimeText(props: Record<string, unknown>) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
|
||||
const app = createApp(ElapsedTimeText, props)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('ElapsedTimeText', () => {
|
||||
it('uses the supplied display clock for active requests', () => {
|
||||
const root = mountElapsedTimeText({
|
||||
createdAt: '2026-05-28T12:00:00Z',
|
||||
status: 'streaming',
|
||||
responseTimeMs: 10_000,
|
||||
displayNowMs: Date.parse('2026-05-28T12:00:40Z'),
|
||||
})
|
||||
|
||||
expect(root.textContent).toBe('40.00s')
|
||||
})
|
||||
|
||||
it('keeps terminal requests pinned to the backend final duration', () => {
|
||||
const root = mountElapsedTimeText({
|
||||
createdAt: '2026-05-28T12:00:00Z',
|
||||
status: 'completed',
|
||||
responseTimeMs: 42_340,
|
||||
displayNowMs: Date.parse('2026-05-28T12:01:30Z'),
|
||||
})
|
||||
|
||||
expect(root.textContent).toBe('42.34s')
|
||||
})
|
||||
|
||||
it('clamps active elapsed time at zero when the display clock is behind', () => {
|
||||
const root = mountElapsedTimeText({
|
||||
createdAt: '2026-05-28T12:00:40Z',
|
||||
status: 'pending',
|
||||
displayNowMs: Date.parse('2026-05-28T12:00:00Z'),
|
||||
})
|
||||
|
||||
expect(root.textContent).toBe('0.00s')
|
||||
})
|
||||
})
|
||||
@@ -91,16 +91,8 @@ vi.mock('lucide-vue-next', async () => {
|
||||
vi.mock('../ElapsedTimeText.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'ElapsedTimeTextStub',
|
||||
props: {
|
||||
displayNowMs: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('span', {
|
||||
'data-display-now-ms': props.displayNowMs == null ? '' : String(props.displayNowMs),
|
||||
}, 'elapsed')
|
||||
setup() {
|
||||
return () => h('span', 'elapsed')
|
||||
},
|
||||
}),
|
||||
}))
|
||||
@@ -251,16 +243,6 @@ describe('UsageRecordsTable', () => {
|
||||
expect(root.querySelector('[data-active-latency-state="waiting-first-byte"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('passes the shared display clock to active elapsed text', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
status: 'streaming',
|
||||
response_time_ms: null,
|
||||
first_byte_time_ms: 500,
|
||||
})], { displayNowMs: 1_779_999_000_000 })
|
||||
|
||||
expect(root.querySelector('[data-display-now-ms="1779999000000"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows failed when Codex image progress fails before the usage record finalizes', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
status: 'pending',
|
||||
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useActiveElapsedDisplayClock } from '../useActiveElapsedDisplayClock'
|
||||
|
||||
type TestRecord = {
|
||||
status: string
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useActiveElapsedDisplayClock', () => {
|
||||
it('ticks only while visible active records exist', async () => {
|
||||
vi.useFakeTimers()
|
||||
let nowMs = 1_000
|
||||
|
||||
const records = ref<TestRecord[]>([])
|
||||
const isPageVisible = ref(true)
|
||||
const serverClockOffsetMs = ref(0)
|
||||
const hasServerClockOffset = ref(false)
|
||||
const clock = useActiveElapsedDisplayClock({
|
||||
records,
|
||||
isPageVisible,
|
||||
serverClockOffsetMs,
|
||||
hasServerClockOffset,
|
||||
resolveStatus: record => record.status,
|
||||
intervalMs: 250,
|
||||
now: () => nowMs,
|
||||
})
|
||||
|
||||
expect(clock.hasVisibleActiveRecords.value).toBe(false)
|
||||
expect(clock.displayNowMs.value).toBe(1_000)
|
||||
|
||||
nowMs = 1_250
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(clock.displayNowMs.value).toBe(1_000)
|
||||
|
||||
records.value = [{ status: 'streaming' }]
|
||||
await nextTick()
|
||||
expect(clock.hasVisibleActiveRecords.value).toBe(true)
|
||||
expect(clock.displayNowMs.value).toBe(1_250)
|
||||
|
||||
nowMs = 1_500
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(clock.displayNowMs.value).toBe(1_500)
|
||||
|
||||
isPageVisible.value = false
|
||||
await nextTick()
|
||||
nowMs = 1_750
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(clock.displayNowMs.value).toBe(1_500)
|
||||
|
||||
clock.stopActiveElapsedDisplayClock()
|
||||
})
|
||||
|
||||
it('applies server clock offset to the display time', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const clock = useActiveElapsedDisplayClock({
|
||||
records: ref<TestRecord[]>([{ status: 'pending' }]),
|
||||
isPageVisible: ref(true),
|
||||
serverClockOffsetMs: ref(-11_000),
|
||||
hasServerClockOffset: ref(true),
|
||||
resolveStatus: record => record.status,
|
||||
intervalMs: 250,
|
||||
now: () => 2_000,
|
||||
})
|
||||
|
||||
expect(clock.calibratedDisplayNowMs.value).toBe(-9_000)
|
||||
clock.stopActiveElapsedDisplayClock()
|
||||
})
|
||||
|
||||
it('stops when active records disappear', async () => {
|
||||
vi.useFakeTimers()
|
||||
let nowMs = 3_000
|
||||
|
||||
const records = ref<TestRecord[]>([{ status: 'pending' }])
|
||||
const clock = useActiveElapsedDisplayClock({
|
||||
records,
|
||||
isPageVisible: ref(true),
|
||||
serverClockOffsetMs: ref(0),
|
||||
hasServerClockOffset: ref(false),
|
||||
resolveStatus: record => record.status,
|
||||
intervalMs: 250,
|
||||
now: () => nowMs,
|
||||
})
|
||||
|
||||
nowMs = 3_250
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(clock.displayNowMs.value).toBe(3_250)
|
||||
|
||||
records.value = [{ status: 'completed' }]
|
||||
await nextTick()
|
||||
nowMs = 3_500
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(clock.displayNowMs.value).toBe(3_250)
|
||||
|
||||
clock.stopActiveElapsedDisplayClock()
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
calculateServerClockOffsetMs,
|
||||
shouldUseServerClockSample,
|
||||
useServerClock
|
||||
} from '../useServerClock'
|
||||
|
||||
describe('useServerClock', () => {
|
||||
it('calculates offset from the response receive time', () => {
|
||||
const offset = calculateServerClockOffsetMs({
|
||||
server_now_unix_ms: 10_500,
|
||||
client_send_unix_ms: 20_000,
|
||||
client_receive_unix_ms: 20_200,
|
||||
round_trip_ms: 200,
|
||||
})
|
||||
|
||||
expect(offset).toBe(-9_700)
|
||||
})
|
||||
|
||||
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,
|
||||
round_trip_ms: 200,
|
||||
})).toBeNull()
|
||||
expect(calculateServerClockOffsetMs({
|
||||
server_now_unix_ms: 10_500,
|
||||
client_send_unix_ms: 20_200,
|
||||
client_receive_unix_ms: 20_000,
|
||||
round_trip_ms: 200,
|
||||
})).toBeNull()
|
||||
expect(calculateServerClockOffsetMs({
|
||||
server_now_unix_ms: 10_500,
|
||||
client_send_unix_ms: 20_000,
|
||||
client_receive_unix_ms: 20_200,
|
||||
round_trip_ms: Number.NaN,
|
||||
})).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,
|
||||
round_trip_ms: 200,
|
||||
})
|
||||
clock.updateServerClockOffset(undefined)
|
||||
|
||||
expect(clock.hasServerClockOffset.value).toBe(true)
|
||||
expect(clock.serverClockOffsetMs.value).toBe(-9_700)
|
||||
expect(clock.serverClockSampleRoundTripMs.value).toBe(200)
|
||||
})
|
||||
|
||||
it('does not let a much slower sample overwrite a better clock offset', () => {
|
||||
const clock = useServerClock()
|
||||
|
||||
clock.updateServerClockOffset({
|
||||
server_now_unix_ms: 10_500,
|
||||
client_send_unix_ms: 20_000,
|
||||
client_receive_unix_ms: 20_050,
|
||||
round_trip_ms: 50,
|
||||
})
|
||||
clock.updateServerClockOffset({
|
||||
server_now_unix_ms: 20_500,
|
||||
client_send_unix_ms: 30_000,
|
||||
client_receive_unix_ms: 30_500,
|
||||
round_trip_ms: 500,
|
||||
})
|
||||
|
||||
expect(clock.serverClockOffsetMs.value).toBe(-9_550)
|
||||
expect(clock.serverClockSampleRoundTripMs.value).toBe(50)
|
||||
})
|
||||
|
||||
it('accepts a faster sample after an initial slow sample', () => {
|
||||
const clock = useServerClock()
|
||||
|
||||
clock.updateServerClockOffset({
|
||||
server_now_unix_ms: 10_500,
|
||||
client_send_unix_ms: 20_000,
|
||||
client_receive_unix_ms: 20_500,
|
||||
round_trip_ms: 500,
|
||||
})
|
||||
clock.updateServerClockOffset({
|
||||
server_now_unix_ms: 20_500,
|
||||
client_send_unix_ms: 30_000,
|
||||
client_receive_unix_ms: 30_050,
|
||||
round_trip_ms: 50,
|
||||
})
|
||||
|
||||
expect(clock.serverClockOffsetMs.value).toBe(-9_550)
|
||||
expect(clock.serverClockSampleRoundTripMs.value).toBe(50)
|
||||
})
|
||||
|
||||
it('allows small RTT regressions so the offset can stay fresh', () => {
|
||||
expect(shouldUseServerClockSample(140, 50)).toBe(true)
|
||||
expect(shouldUseServerClockSample(151, 50)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
export { useUsageData } from './useUsageData'
|
||||
export { useActiveElapsedDisplayClock } from './useActiveElapsedDisplayClock'
|
||||
export { useUsageFilters } from './useUsageFilters'
|
||||
export { useUsagePagination } from './useUsagePagination'
|
||||
export { getDateRangeFromPeriod, formatDateTime, getSuccessRateColor } from './useDateRange'
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
type ActiveElapsedStatus = string | null | undefined
|
||||
|
||||
export interface UseActiveElapsedDisplayClockOptions<TRecord> {
|
||||
records: Ref<TRecord[]> | ComputedRef<TRecord[]>
|
||||
isPageVisible: Ref<boolean> | ComputedRef<boolean>
|
||||
serverClockOffsetMs: Ref<number> | ComputedRef<number>
|
||||
hasServerClockOffset: Ref<boolean> | ComputedRef<boolean>
|
||||
resolveStatus: (record: TRecord) => ActiveElapsedStatus
|
||||
intervalMs?: number
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export function useActiveElapsedDisplayClock<TRecord>(
|
||||
options: UseActiveElapsedDisplayClockOptions<TRecord>
|
||||
) {
|
||||
const intervalMs = options.intervalMs ?? 250
|
||||
const now = options.now ?? Date.now
|
||||
const displayNowMs = ref(now())
|
||||
|
||||
let activeElapsedDisplayTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const hasVisibleActiveRecords = computed(() => {
|
||||
return options.records.value.some((record) => {
|
||||
const displayStatus = options.resolveStatus(record)
|
||||
return displayStatus === 'pending' || displayStatus === 'streaming'
|
||||
})
|
||||
})
|
||||
|
||||
const calibratedDisplayNowMs = computed(() => {
|
||||
return options.hasServerClockOffset.value
|
||||
? displayNowMs.value + options.serverClockOffsetMs.value
|
||||
: displayNowMs.value
|
||||
})
|
||||
|
||||
function tickActiveElapsedDisplay() {
|
||||
displayNowMs.value = now()
|
||||
}
|
||||
|
||||
function startActiveElapsedDisplayTimer() {
|
||||
if (activeElapsedDisplayTimer) return
|
||||
if (!options.isPageVisible.value || !hasVisibleActiveRecords.value) return
|
||||
tickActiveElapsedDisplay()
|
||||
activeElapsedDisplayTimer = setInterval(tickActiveElapsedDisplay, intervalMs)
|
||||
}
|
||||
|
||||
function stopActiveElapsedDisplayTimer() {
|
||||
if (activeElapsedDisplayTimer) {
|
||||
clearInterval(activeElapsedDisplayTimer)
|
||||
activeElapsedDisplayTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function syncActiveElapsedDisplayTimer() {
|
||||
if (options.isPageVisible.value && hasVisibleActiveRecords.value) {
|
||||
startActiveElapsedDisplayTimer()
|
||||
} else {
|
||||
stopActiveElapsedDisplayTimer()
|
||||
}
|
||||
}
|
||||
|
||||
const stopActiveElapsedDisplayWatch = watch(
|
||||
[hasVisibleActiveRecords, options.isPageVisible],
|
||||
syncActiveElapsedDisplayTimer,
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function stopActiveElapsedDisplayClock() {
|
||||
stopActiveElapsedDisplayWatch()
|
||||
stopActiveElapsedDisplayTimer()
|
||||
}
|
||||
|
||||
return {
|
||||
displayNowMs,
|
||||
calibratedDisplayNowMs,
|
||||
hasVisibleActiveRecords,
|
||||
tickActiveElapsedDisplay,
|
||||
startActiveElapsedDisplayTimer,
|
||||
stopActiveElapsedDisplayTimer,
|
||||
syncActiveElapsedDisplayTimer,
|
||||
stopActiveElapsedDisplayClock,
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ServerTimingMetadata } from '@/api/serverTiming'
|
||||
|
||||
const SERVER_CLOCK_RTT_REGRESSION_TOLERANCE_MS = 100
|
||||
|
||||
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,
|
||||
round_trip_ms: roundTripMs,
|
||||
} = timing
|
||||
|
||||
if (
|
||||
!Number.isFinite(serverNowUnixMs) ||
|
||||
!Number.isFinite(clientSendUnixMs) ||
|
||||
!Number.isFinite(clientReceiveUnixMs) ||
|
||||
!Number.isFinite(roundTripMs)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (clientReceiveUnixMs < clientSendUnixMs || roundTripMs < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return serverNowUnixMs - clientReceiveUnixMs
|
||||
}
|
||||
|
||||
export function shouldUseServerClockSample(
|
||||
nextRoundTripMs: number,
|
||||
currentRoundTripMs: number | null | undefined
|
||||
): boolean {
|
||||
if (!Number.isFinite(nextRoundTripMs) || nextRoundTripMs < 0) return false
|
||||
if (currentRoundTripMs == null || !Number.isFinite(currentRoundTripMs)) return true
|
||||
return nextRoundTripMs <= currentRoundTripMs + SERVER_CLOCK_RTT_REGRESSION_TOLERANCE_MS
|
||||
}
|
||||
|
||||
export function useServerClock() {
|
||||
const serverClockOffsetMs = ref(0)
|
||||
const hasServerClockOffset = ref(false)
|
||||
const serverClockSampleRoundTripMs = ref<number | null>(null)
|
||||
|
||||
function updateServerClockOffset(timing: ServerTimingMetadata | null | undefined): void {
|
||||
const offsetMs = calculateServerClockOffsetMs(timing)
|
||||
if (offsetMs == null) return
|
||||
if (!shouldUseServerClockSample(timing?.round_trip_ms ?? Number.NaN, serverClockSampleRoundTripMs.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
serverClockOffsetMs.value = offsetMs
|
||||
serverClockSampleRoundTripMs.value = timing?.round_trip_ms ?? null
|
||||
hasServerClockOffset.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
serverClockOffsetMs,
|
||||
hasServerClockOffset,
|
||||
serverClockSampleRoundTripMs,
|
||||
updateServerClockOffset,
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ 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>
|
||||
@@ -66,11 +65,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
// 可用的筛选选项(从统计数据获取,而不是从记录中)
|
||||
const availableModels = ref<string[]>([])
|
||||
const availableProviders = ref<string[]>([])
|
||||
const {
|
||||
serverClockOffsetMs,
|
||||
hasServerClockOffset,
|
||||
updateServerClockOffset,
|
||||
} = useServerClock()
|
||||
|
||||
// 增强的模型统计(包含效率分析)
|
||||
const enhancedModelStats = computed<EnhancedModelStatsItem[]>(() => {
|
||||
@@ -219,7 +213,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
if (requestId !== loadStatsRequestId) {
|
||||
return false
|
||||
}
|
||||
updateServerClockOffset(userData.server_timing)
|
||||
|
||||
stats.value = {
|
||||
total_requests: userData.total_requests || 0,
|
||||
@@ -373,7 +366,6 @@ 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
|
||||
@@ -383,7 +375,6 @@ 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
|
||||
@@ -566,8 +557,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
apiFormatStats,
|
||||
currentRecords,
|
||||
totalRecords,
|
||||
serverClockOffsetMs,
|
||||
hasServerClockOffset,
|
||||
|
||||
// 筛选选项
|
||||
availableModels,
|
||||
@@ -579,7 +568,6 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
// 方法
|
||||
loadStats,
|
||||
loadRecords,
|
||||
refreshData,
|
||||
updateServerClockOffset
|
||||
refreshData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@
|
||||
:total-records="effectiveTotalRecords"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:auto-refresh="globalAutoRefresh"
|
||||
:display-now-ms="calibratedDisplayNowMs"
|
||||
@update:time-range="handleTimeRangeChange"
|
||||
@update:filter-search="handleFilterSearchChange"
|
||||
@update:filter-user="handleFilterUserChange"
|
||||
@@ -149,7 +148,6 @@ import {
|
||||
IntervalTimelineCard
|
||||
} from '@/features/usage/components'
|
||||
import {
|
||||
useActiveElapsedDisplayClock,
|
||||
useUsageData,
|
||||
getDateRangeFromPeriod
|
||||
} from '@/features/usage/composables'
|
||||
@@ -248,10 +246,7 @@ const {
|
||||
availableModels,
|
||||
availableProviders,
|
||||
loadStats,
|
||||
loadRecords,
|
||||
serverClockOffsetMs,
|
||||
hasServerClockOffset,
|
||||
updateServerClockOffset
|
||||
loadRecords
|
||||
} = useUsageData({ isAdminPage })
|
||||
|
||||
// 热力图状态
|
||||
@@ -439,7 +434,6 @@ const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次(用于活跃请求)
|
||||
const ACTIVE_DISCOVERY_HOT_INTERVAL = 1000 // 有活跃请求时 1 秒扫描一次
|
||||
const ACTIVE_DISCOVERY_IDLE_INTERVAL = 5000 // 空闲时降频,避免后台持续刷日志
|
||||
const GLOBAL_AUTO_REFRESH_INTERVAL = 3000 // 3秒刷新一次(全局自动刷新)
|
||||
const ACTIVE_ELAPSED_DISPLAY_INTERVAL = 250 // 共享显示时钟,避免每行单独动画
|
||||
const globalAutoRefresh = ref(false) // 全局自动刷新开关(默认关闭)
|
||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||
|
||||
@@ -451,14 +445,10 @@ const discoveredActiveRequestIds = new Set<string>()
|
||||
|
||||
async function loadActiveRequestUpdates(ids?: string[]) {
|
||||
if (isAdminPage.value) {
|
||||
const result = await usageApi.getActiveRequests(ids, timeRange.value)
|
||||
updateServerClockOffset(result.server_timing)
|
||||
return result
|
||||
return usageApi.getActiveRequests(ids, timeRange.value)
|
||||
}
|
||||
const idsParam = ids?.length ? ids.join(',') : undefined
|
||||
const result = await meApi.getActiveRequests(idsParam)
|
||||
updateServerClockOffset(result.server_timing)
|
||||
return result
|
||||
return meApi.getActiveRequests(idsParam)
|
||||
}
|
||||
|
||||
async function pollActiveRequests() {
|
||||
@@ -724,10 +714,8 @@ function handleVisibilityChange() {
|
||||
stopAutoRefresh()
|
||||
stopActiveDiscovery()
|
||||
stopGlobalAutoRefresh()
|
||||
stopActiveElapsedDisplayTimer()
|
||||
return
|
||||
}
|
||||
syncActiveElapsedDisplayTimer()
|
||||
if (hasActiveRequests.value) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
@@ -744,7 +732,6 @@ onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
stopActiveDiscovery()
|
||||
stopGlobalAutoRefresh()
|
||||
stopActiveElapsedDisplayClock()
|
||||
})
|
||||
|
||||
// 用户页面的前端分页(后端一次性返回所有记录,前端分页+筛选)
|
||||
@@ -768,20 +755,6 @@ const effectiveTotalRecords = computed(() => {
|
||||
// 显示的记录
|
||||
const displayRecords = computed(() => paginatedRecords.value)
|
||||
|
||||
const {
|
||||
calibratedDisplayNowMs,
|
||||
stopActiveElapsedDisplayTimer,
|
||||
syncActiveElapsedDisplayTimer,
|
||||
stopActiveElapsedDisplayClock,
|
||||
} = useActiveElapsedDisplayClock({
|
||||
records: displayRecords,
|
||||
isPageVisible,
|
||||
serverClockOffsetMs,
|
||||
hasServerClockOffset,
|
||||
resolveStatus: resolveDisplayRequestStatus,
|
||||
intervalMs: ACTIVE_ELAPSED_DISPLAY_INTERVAL,
|
||||
})
|
||||
|
||||
const availableClientFamilies = computed(() => {
|
||||
const families = new Set<string>()
|
||||
currentRecords.value.forEach((record) => {
|
||||
|
||||
Reference in New Issue
Block a user