mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
fix(usage): calibrate active elapsed clock efficiently
This commit is contained in:
@@ -3,25 +3,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useActiveElapsedDisplayNowMs } from '../composables/useActiveElapsedDisplayClock'
|
||||
|
||||
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
|
||||
const injectedDisplayNowMs = useActiveElapsedDisplayNowMs()
|
||||
|
||||
function parseCreatedAtMs(value: string | null | undefined): number {
|
||||
if (!value) return Number.NaN
|
||||
@@ -30,35 +31,6 @@ 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 '-'
|
||||
@@ -70,7 +42,14 @@ const displayText = computed(() => {
|
||||
const createdAtMs = parseCreatedAtMs(props.createdAt)
|
||||
if (Number.isNaN(createdAtMs)) return '-'
|
||||
|
||||
const elapsedMs = Math.max(0, now.value - createdAtMs)
|
||||
// 活跃请求里的 response_time_ms 可能只是首字或中间值;终态才使用后端最终耗时。
|
||||
const injectedNowMs = injectedDisplayNowMs?.value
|
||||
const nowMs = typeof props.displayNowMs === 'number' && Number.isFinite(props.displayNowMs)
|
||||
? props.displayNowMs
|
||||
: typeof injectedNowMs === 'number' && Number.isFinite(injectedNowMs)
|
||||
? injectedNowMs
|
||||
: Date.now()
|
||||
const elapsedMs = Math.max(0, nowMs - createdAtMs)
|
||||
return `${(elapsedMs / 1000).toFixed(precision.value)}s`
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, nextTick, ref, type App } from 'vue'
|
||||
import { ACTIVE_ELAPSED_DISPLAY_NOW_MS_KEY, type ActiveElapsedDisplayNowMsRef } from '../../composables/useActiveElapsedDisplayClock'
|
||||
import ElapsedTimeText from '../ElapsedTimeText.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountElapsedTimeText(
|
||||
props: Record<string, unknown>,
|
||||
options: { providedDisplayNowMs?: ActiveElapsedDisplayNowMsRef } = {}
|
||||
) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
|
||||
const app = createApp(ElapsedTimeText, props)
|
||||
if (options.providedDisplayNowMs) {
|
||||
app.provide(ACTIVE_ELAPSED_DISPLAY_NOW_MS_KEY, options.providedDisplayNowMs)
|
||||
}
|
||||
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('uses the injected shared display clock when no prop is supplied', async () => {
|
||||
const displayNowMs = ref(Date.parse('2026-05-28T12:00:40Z'))
|
||||
const root = mountElapsedTimeText({
|
||||
createdAt: '2026-05-28T12:00:00Z',
|
||||
status: 'streaming',
|
||||
responseTimeMs: 10_000,
|
||||
}, { providedDisplayNowMs: displayNowMs })
|
||||
|
||||
expect(root.textContent).toBe('40.00s')
|
||||
|
||||
displayNowMs.value = Date.parse('2026-05-28T12:00:45Z')
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toBe('45.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,8 +91,15 @@ vi.mock('lucide-vue-next', async () => {
|
||||
vi.mock('../ElapsedTimeText.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'ElapsedTimeTextStub',
|
||||
setup() {
|
||||
return () => h('span', 'elapsed')
|
||||
props: {
|
||||
displayNowMs: {
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('span', {
|
||||
'data-display-now-ms': props.displayNowMs == null ? 'missing' : String(props.displayNowMs),
|
||||
}, 'elapsed')
|
||||
},
|
||||
}),
|
||||
}))
|
||||
@@ -243,6 +250,16 @@ describe('UsageRecordsTable', () => {
|
||||
expect(root.querySelector('[data-active-latency-state="waiting-first-byte"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves shared clock subscription inside active elapsed text', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
status: 'streaming',
|
||||
response_time_ms: null,
|
||||
first_byte_time_ms: 500,
|
||||
})])
|
||||
|
||||
expect(root.querySelector('[data-display-now-ms="missing"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows failed when Codex image progress fails before the usage record finalizes', () => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
status: 'pending',
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App, type Ref } from 'vue'
|
||||
import {
|
||||
provideActiveElapsedDisplayClock,
|
||||
useActiveElapsedDisplayNowMs,
|
||||
} from '../useActiveElapsedDisplayClock'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
function mountApp(component: ReturnType<typeof defineComponent>) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
|
||||
const app = createApp(component)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
describe('active elapsed display clock render scope', () => {
|
||||
it('updates injected elapsed text without re-rendering the surrounding table', async () => {
|
||||
const displayNowMs = ref(1_000)
|
||||
const rows = Array.from({ length: 1_000 }, (_, index) => ({
|
||||
id: `row-${index}`,
|
||||
model: `model-${index}`,
|
||||
}))
|
||||
let tableRenderCount = 0
|
||||
let elapsedRenderCount = 0
|
||||
|
||||
const ElapsedTextProbe = defineComponent({
|
||||
name: 'ElapsedTextProbe',
|
||||
setup() {
|
||||
const injectedDisplayNowMs = useActiveElapsedDisplayNowMs()
|
||||
return () => {
|
||||
elapsedRenderCount += 1
|
||||
return h('span', injectedDisplayNowMs?.value)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const TableProbe = defineComponent({
|
||||
name: 'TableProbe',
|
||||
setup() {
|
||||
return () => {
|
||||
tableRenderCount += 1
|
||||
return h('table', rows.map(row => h('tr', { key: row.id }, [
|
||||
h('td', row.model),
|
||||
row.id === 'row-0' ? h('td', h(ElapsedTextProbe)) : h('td', '-'),
|
||||
])))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const Harness = defineComponent({
|
||||
name: 'Harness',
|
||||
setup() {
|
||||
provideActiveElapsedDisplayClock(displayNowMs as Ref<number>)
|
||||
return () => h(TableProbe)
|
||||
},
|
||||
})
|
||||
|
||||
mountApp(Harness)
|
||||
expect(tableRenderCount).toBe(1)
|
||||
expect(elapsedRenderCount).toBe(1)
|
||||
|
||||
displayNowMs.value = 1_250
|
||||
await nextTick()
|
||||
|
||||
expect(tableRenderCount).toBe(1)
|
||||
expect(elapsedRenderCount).toBe(2)
|
||||
})
|
||||
})
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
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,4 +1,9 @@
|
||||
export { useUsageData } from './useUsageData'
|
||||
export {
|
||||
provideActiveElapsedDisplayClock,
|
||||
useActiveElapsedDisplayClock,
|
||||
useActiveElapsedDisplayNowMs,
|
||||
} from './useActiveElapsedDisplayClock'
|
||||
export { useUsageFilters } from './useUsageFilters'
|
||||
export { useUsagePagination } from './useUsagePagination'
|
||||
export { getDateRangeFromPeriod, formatDateTime, getSuccessRateColor } from './useDateRange'
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { computed, inject, provide, ref, watch, type ComputedRef, type InjectionKey, type Ref } from 'vue'
|
||||
|
||||
type ActiveElapsedStatus = string | null | undefined
|
||||
export interface ActiveElapsedDisplayNowMsRef {
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
export const ACTIVE_ELAPSED_DISPLAY_NOW_MS_KEY: InjectionKey<ActiveElapsedDisplayNowMsRef> =
|
||||
Symbol('activeElapsedDisplayNowMs')
|
||||
|
||||
export function provideActiveElapsedDisplayClock(displayNowMs: ActiveElapsedDisplayNowMsRef): void {
|
||||
provide(ACTIVE_ELAPSED_DISPLAY_NOW_MS_KEY, displayNowMs)
|
||||
}
|
||||
|
||||
export function useActiveElapsedDisplayNowMs(): ActiveElapsedDisplayNowMsRef | undefined {
|
||||
return inject(ACTIVE_ELAPSED_DISPLAY_NOW_MS_KEY, 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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,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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user