Skip to content

Commit cf41ca3

Browse files
Merge pull request #216 from CtriXin/fix/usage-rfc3339-nanoseconds
fix(usage): normalize high-precision RFC3339 timestamps
2 parents 18544b7 + 0c39e8d commit cf41ca3

7 files changed

Lines changed: 86 additions & 18 deletions

File tree

src/components/usage/RequestEventsDetailsCard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@
99
import type { AuthFileItem } from '@/types/authFile';
1010
import type { CredentialInfo } from '@/types/sourceInfo';
1111
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
12+
import { parseTimestampMs } from '@/utils/timestamp';
1213
import {
1314
collectUsageDetails,
1415
extractLatencyMs,
@@ -131,7 +132,7 @@ export function RequestEventsDetailsCard({
131132
const timestampMs =
132133
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
133134
? detail.__timestampMs
134-
: Date.parse(timestamp);
135+
: parseTimestampMs(timestamp);
135136
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
136137
const sourceRaw = String(detail.source ?? '').trim();
137138
const authIndexRaw = detail.auth_index as unknown;

src/features/authFiles/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import iconKimiLight from '@/assets/icons/kimi-light.svg';
99
import iconQwen from '@/assets/icons/qwen.svg';
1010
import iconVertex from '@/assets/icons/vertex.svg';
1111
import type { AuthFileItem } from '@/types';
12+
import { parseTimestamp } from '@/utils/timestamp';
1213
import {
1314
normalizeAuthIndex,
1415
normalizeUsageSourceId,
@@ -279,7 +280,7 @@ export const formatModified = (item: AuthFileItem): string => {
279280
const date =
280281
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
281282
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
282-
: new Date(String(raw));
283+
: parseTimestamp(raw) ?? new Date(String(raw));
283284
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
284285
};
285286

src/pages/hooks/useTraceResolver.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
55
import type { AuthFileItem, Config } from '@/types';
66
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
77
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
8+
import { parseTimestampMs } from '@/utils/timestamp';
89
import {
910
collectUsageDetailsWithEndpoint,
1011
normalizeAuthIndex,
@@ -183,7 +184,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
183184
if (!logPath) return [];
184185

185186
const logTimestampMs = traceLogLine.timestamp
186-
? Date.parse(traceLogLine.timestamp)
187+
? parseTimestampMs(traceLogLine.timestamp)
187188
: Number.NaN;
188189

189190
// Step 1: filter by path match

src/services/api/authFiles.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { apiClient } from './client';
66
import type { AuthFilesResponse } from '@/types/authFile';
77
import type { OAuthModelAliasEntry } from '@/types';
8+
import { parseTimestampMs } from '@/utils/timestamp';
89

910
type StatusError = { status?: number };
1011
type AuthFileStatusResponse = { status: string; disabled: boolean };
@@ -185,7 +186,7 @@ const readDateField = (entry: AuthFileEntry): number => {
185186
if (Number.isFinite(asNumber)) {
186187
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
187188
}
188-
const parsed = Date.parse(trimmed);
189+
const parsed = parseTimestampMs(trimmed);
189190
if (!Number.isNaN(parsed)) {
190191
return parsed;
191192
}

src/utils/format.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { parseTimestamp } from './timestamp';
2+
13
/**
24
* 格式化工具函数
35
* 从原项目 src/utils/string.js 迁移
@@ -47,7 +49,7 @@ export function formatFileSize(bytes: number): string {
4749
* 格式化日期时间
4850
*/
4951
export function formatDateTime(date: string | Date, locale?: string): string {
50-
const d = typeof date === 'string' ? new Date(date) : date;
52+
const d = typeof date === 'string' ? parseTimestamp(date) ?? new Date(date) : date;
5153

5254
if (isNaN(d.getTime())) {
5355
return 'Invalid Date';
@@ -73,7 +75,7 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string {
7375
const asNumber = typeof value === 'number' ? value : Number(value);
7476
const date = (() => {
7577
if (!Number.isFinite(asNumber) || Number.isNaN(asNumber)) {
76-
return new Date(String(value));
78+
return parseTimestamp(value) ?? new Date(String(value));
7779
}
7880

7981
const abs = Math.abs(asNumber);

src/utils/timestamp.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
const RFC3339_HIGH_PRECISION_REGEX =
2+
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/i;
3+
4+
/**
5+
* Some browsers mis-handle RFC3339 timestamps that include sub-millisecond
6+
* precision. Normalize them to millisecond precision before parsing.
7+
*/
8+
export function normalizeTimestampForDateParse(value: string): string {
9+
const trimmed = value.trim();
10+
if (!trimmed) return '';
11+
12+
const match = trimmed.match(RFC3339_HIGH_PRECISION_REGEX);
13+
if (!match) return trimmed;
14+
15+
const [, base, , fractionDigits = '', timezone = ''] = match;
16+
if (fractionDigits.length <= 3) {
17+
return trimmed;
18+
}
19+
20+
return `${base}.${fractionDigits.slice(0, 3)}${timezone}`;
21+
}
22+
23+
export function parseTimestampMs(value: unknown): number {
24+
if (typeof value === 'number' && Number.isFinite(value)) {
25+
return value;
26+
}
27+
if (value instanceof Date) {
28+
return value.getTime();
29+
}
30+
if (typeof value !== 'string') {
31+
return Number.NaN;
32+
}
33+
34+
const trimmed = value.trim();
35+
if (!trimmed) {
36+
return Number.NaN;
37+
}
38+
39+
const normalized = normalizeTimestampForDateParse(trimmed);
40+
const normalizedParsed = Date.parse(normalized);
41+
if (!Number.isNaN(normalizedParsed)) {
42+
return normalizedParsed;
43+
}
44+
45+
if (normalized !== trimmed) {
46+
const originalParsed = Date.parse(trimmed);
47+
if (!Number.isNaN(originalParsed)) {
48+
return originalParsed;
49+
}
50+
}
51+
52+
return Number.NaN;
53+
}
54+
55+
export function parseTimestamp(value: unknown): Date | null {
56+
const timestampMs = parseTimestampMs(value);
57+
if (!Number.isFinite(timestampMs)) {
58+
return null;
59+
}
60+
return new Date(timestampMs);
61+
}

src/utils/usage.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
finalizeLatencyStats,
1414
} from './usage/latency';
1515
import { maskApiKey } from './format';
16+
import { parseTimestampMs } from './timestamp';
1617

1718
export type { DurationFormatOptions, LatencyStats } from './usage/latency';
1819
export {
@@ -195,7 +196,7 @@ export function filterUsageByTimeRange<T>(
195196
if (!detailRecord || typeof detailRecord.timestamp !== 'string') {
196197
return;
197198
}
198-
const timestamp = Date.parse(detailRecord.timestamp);
199+
const timestamp = parseTimestampMs(detailRecord.timestamp);
199200
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > nowMs) {
200201
return;
201202
}
@@ -545,7 +546,7 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
545546
modelDetails.forEach((detailRaw) => {
546547
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
547548
const timestamp = detailRaw.timestamp;
548-
const timestampMs = Date.parse(timestamp);
549+
const timestampMs = parseTimestampMs(timestamp);
549550
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
550551
const latencyMs = extractLatencyMs(detailRaw);
551552
details.push({
@@ -618,7 +619,7 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
618619
modelDetails.forEach((detailRaw) => {
619620
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
620621
const timestamp = detailRaw.timestamp;
621-
const timestampMs = Date.parse(timestamp);
622+
const timestampMs = parseTimestampMs(timestamp);
622623
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
623624
const latencyMs = extractLatencyMs(detailRaw);
624625
details.push({
@@ -721,7 +722,7 @@ export function calculateRecentPerMinuteRates(
721722
const timestamp =
722723
typeof detail.__timestampMs === 'number'
723724
? detail.__timestampMs
724-
: Date.parse(detail.timestamp);
725+
: parseTimestampMs(detail.timestamp);
725726
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
726727
return;
727728
}
@@ -1131,7 +1132,7 @@ export function buildHourlySeriesByModel(
11311132
const timestamp =
11321133
typeof detail.__timestampMs === 'number'
11331134
? detail.__timestampMs
1134-
: Date.parse(detail.timestamp);
1135+
: parseTimestampMs(detail.timestamp);
11351136
if (!Number.isFinite(timestamp) || timestamp <= 0) {
11361137
return;
11371138
}
@@ -1190,7 +1191,7 @@ export function buildDailySeriesByModel(
11901191
const timestamp =
11911192
typeof detail.__timestampMs === 'number'
11921193
? detail.__timestampMs
1193-
: Date.parse(detail.timestamp);
1194+
: parseTimestampMs(detail.timestamp);
11941195
if (!Number.isFinite(timestamp) || timestamp <= 0) {
11951196
return;
11961197
}
@@ -1416,7 +1417,7 @@ export function calculateStatusBarData(
14161417
const timestamp =
14171418
typeof detail.__timestampMs === 'number'
14181419
? detail.__timestampMs
1419-
: Date.parse(detail.timestamp);
1420+
: parseTimestampMs(detail.timestamp);
14201421
if (
14211422
!Number.isFinite(timestamp) ||
14221423
timestamp <= 0 ||
@@ -1524,7 +1525,7 @@ export function calculateServiceHealthData(usageDetails: UsageDetail[]): Service
15241525
const timestamp =
15251526
typeof detail.__timestampMs === 'number'
15261527
? detail.__timestampMs
1527-
: Date.parse(detail.timestamp);
1528+
: parseTimestampMs(detail.timestamp);
15281529
if (
15291530
!Number.isFinite(timestamp) ||
15301531
timestamp <= 0 ||
@@ -1734,7 +1735,7 @@ export function buildHourlyTokenBreakdown(
17341735
const timestamp =
17351736
typeof detail.__timestampMs === 'number'
17361737
? detail.__timestampMs
1737-
: Date.parse(detail.timestamp);
1738+
: parseTimestampMs(detail.timestamp);
17381739
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
17391740
const normalized = new Date(timestamp);
17401741
normalized.setMinutes(0, 0, 0);
@@ -1776,7 +1777,7 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
17761777
const timestamp =
17771778
typeof detail.__timestampMs === 'number'
17781779
? detail.__timestampMs
1779-
: Date.parse(detail.timestamp);
1780+
: parseTimestampMs(detail.timestamp);
17801781
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
17811782
const dayLabel = formatDayLabel(new Date(timestamp));
17821783
if (!dayLabel) return;
@@ -1853,7 +1854,7 @@ export function buildHourlyCostSeries(
18531854
const timestamp =
18541855
typeof detail.__timestampMs === 'number'
18551856
? detail.__timestampMs
1856-
: Date.parse(detail.timestamp);
1857+
: parseTimestampMs(detail.timestamp);
18571858
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
18581859
const normalized = new Date(timestamp);
18591860
normalized.setMinutes(0, 0, 0);
@@ -1888,7 +1889,7 @@ export function buildDailyCostSeries(
18881889
const timestamp =
18891890
typeof detail.__timestampMs === 'number'
18901891
? detail.__timestampMs
1891-
: Date.parse(detail.timestamp);
1892+
: parseTimestampMs(detail.timestamp);
18921893
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
18931894
const dayLabel = formatDayLabel(new Date(timestamp));
18941895
if (!dayLabel) return;

0 commit comments

Comments
 (0)