fix: localize downstream key trend timestamps (#432)

* fix: localize downstream key trend timestamps

* fix: keep downstream trend metadata on empty responses

* fix: cursor paginate downstream key trend reads

* fix: normalize empty downstream trend timezones

* fix: localize windowed downstream trend buckets
This commit is contained in:
Cita
2026-04-06 08:46:40 +08:00
committed by GitHub
parent 3e8d69bed6
commit 287c5aafd6
8 changed files with 800 additions and 89 deletions
+26 -4
View File
@@ -134,12 +134,12 @@ async function fetchAuthenticatedResponse(url: string, options: RequestOptions =
}
}
async function request(url: string, options: RequestOptions = {}) {
async function request<T = any>(url: string, options: RequestOptions = {}): Promise<T> {
const res = await fetchAuthenticatedResponse(url, options);
if (!res.ok) {
throw new Error(await extractResponseErrorMessage(res));
}
return res.json();
return res.json() as Promise<T>;
}
async function streamSse(
@@ -686,6 +686,28 @@ export type OAuthImportResponse = {
}>;
};
export type DownstreamApiKeyTrendBucket = {
startUtc: string | null;
totalRequests: number;
successRequests: number;
failedRequests: number;
successRate: number | null;
totalTokens: number;
totalCost: number;
};
export type DownstreamApiKeyTrendResponse = {
success: boolean;
range: '24h' | '7d' | 'all';
item: {
id: number;
name: string;
};
bucketSeconds: number;
timeZone?: string | null;
buckets: DownstreamApiKeyTrendBucket[];
};
export const api = {
// Sites
getSites: () => request('/api/sites'),
@@ -949,8 +971,8 @@ export const api = {
getDownstreamApiKeysSummary: (params?: { range?: '24h' | '7d' | 'all'; status?: 'all' | 'enabled' | 'disabled'; search?: string }) =>
request(`/api/downstream-keys/summary${buildQueryString(params)}`),
getDownstreamApiKeyOverview: (id: number) => request(`/api/downstream-keys/${id}/overview`),
getDownstreamApiKeyTrend: (id: number, params?: { range?: '24h' | '7d' | 'all' }) =>
request(`/api/downstream-keys/${id}/trend${buildQueryString(params)}`),
getDownstreamApiKeyTrend: (id: number, params?: { range?: '24h' | '7d' | 'all'; timeZone?: string }) =>
request<DownstreamApiKeyTrendResponse>(`/api/downstream-keys/${id}/trend${buildQueryString(params)}`),
exportBackup: (type: 'all' | 'accounts' | 'preferences' = 'all') =>
request(`/api/settings/backup/export?type=${encodeURIComponent(type)}`),
importBackup: (data: any) =>
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import DownstreamKeyTrendChart from './DownstreamKeyTrendChart.js';
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
const vChartSpy = vi.fn();
vi.mock('@visactor/react-vchart', () => ({
VChart: (props: Record<string, unknown>) => {
vChartSpy(props);
return null;
},
}));
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function formatExpectedAxisLabel(raw: string, bucketSeconds: number): string {
const parsed = new Date(raw);
const month = pad2(parsed.getMonth() + 1);
const day = pad2(parsed.getDate());
if (bucketSeconds >= 86400) return `${month}/${day}`;
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
}
describe('DownstreamKeyTrendChart', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
vChartSpy.mockClear();
globalThis.document = {
documentElement: {
getAttribute: vi.fn(),
},
} as unknown as Document;
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('formats trend bucket timestamps with local display time', async () => {
const bucketStart = '2026-04-05T15:00:00.000Z';
let renderer!: ReactTestRenderer;
await expect(act(async () => {
renderer = create(
<DownstreamKeyTrendChart
bucketSeconds={3600}
buckets={[
{
startUtc: bucketStart,
totalRequests: 2,
totalTokens: 2128425,
totalCost: 2.128425,
successRate: 100,
},
]}
/>,
);
})).resolves.toBeUndefined();
expect(vChartSpy).toHaveBeenCalledTimes(1);
const spec = vChartSpy.mock.calls[0]?.[0]?.spec as {
data?: Array<{ values?: Array<{ date?: string; tooltipDate?: string }> }>;
axes?: Array<{
label?: {
formatMethod?: (value: string) => string;
};
}>;
tooltip?: {
dimension?: {
title?: {
value?: (datum: Record<string, unknown>) => string;
};
};
};
};
const expectedLabel = formatDateTimeMinuteLocal(bucketStart);
expect(spec.data?.[0]?.values?.[0]?.tooltipDate).toBe(expectedLabel);
expect(spec.tooltip?.dimension?.title?.value?.({ tooltipDate: expectedLabel })).toBe(expectedLabel);
expect(spec.axes?.[0]?.label?.formatMethod?.(bucketStart)).toBe(formatExpectedAxisLabel(bucketStart, 3600));
renderer.unmount();
});
it('uses day-only axis labels for daily buckets', async () => {
const bucketStart = '2026-04-05T16:00:00.000Z';
let renderer!: ReactTestRenderer;
await expect(act(async () => {
renderer = create(
<DownstreamKeyTrendChart
bucketSeconds={86400}
buckets={[
{
startUtc: bucketStart,
totalRequests: 3,
totalTokens: 600,
totalCost: 0.06,
successRate: 66.7,
},
]}
/>,
);
})).resolves.toBeUndefined();
const spec = vChartSpy.mock.calls[0]?.[0]?.spec as {
axes?: Array<{
label?: {
formatMethod?: (value: string) => string;
};
}>;
};
expect(spec.axes?.[0]?.label?.formatMethod?.(bucketStart)).toBe(formatExpectedAxisLabel(bucketStart, 86400));
renderer.unmount();
});
});
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
type Metric = 'tokens' | 'requests' | 'cost';
@@ -17,12 +18,27 @@ export type DownstreamKeyTrendBucket = {
successRate: number | null;
};
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function formatTrendAxisLabel(raw: string, bucketSeconds: number): string {
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
const month = pad2(parsed.getMonth() + 1);
const day = pad2(parsed.getDate());
if (bucketSeconds >= 86400) return `${month}/${day}`;
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
}
export default function DownstreamKeyTrendChart({
buckets,
bucketSeconds = 3600,
loading,
height = 260,
}: {
buckets: DownstreamKeyTrendBucket[];
bucketSeconds?: number;
loading?: boolean;
height?: number;
}) {
@@ -32,13 +48,17 @@ export default function DownstreamKeyTrendChart({
if (!Array.isArray(buckets) || buckets.length === 0) return [];
return buckets
.map((bucket) => {
const date = bucket.startUtc || '';
const rawDate = (bucket.startUtc || '').trim();
const value = metric === 'tokens'
? Number(bucket.totalTokens || 0)
: (metric === 'requests'
? Number(bucket.totalRequests || 0)
: Number(bucket.totalCost || 0));
return { date, value };
return {
date: rawDate,
tooltipDate: rawDate ? formatDateTimeMinuteLocal(rawDate) : '',
value,
};
})
.filter((row) => row.date.length > 0);
}, [buckets, metric]);
@@ -89,7 +109,10 @@ export default function DownstreamKeyTrendChart({
axes: [
{
orient: 'bottom',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
label: {
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
formatMethod: (value: string) => formatTrendAxisLabel(String(value || ''), bucketSeconds),
},
domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } },
},
@@ -102,7 +125,7 @@ export default function DownstreamKeyTrendChart({
],
tooltip: {
dimension: {
title: { value: (datum: Record<string, unknown>) => String(datum?.date || '') },
title: { value: (datum: Record<string, unknown>) => String(datum?.tooltipDate || datum?.date || '') },
content: [
{
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
@@ -1,8 +1,9 @@
import React, { Suspense, lazy, useEffect, useState } from 'react';
import React, { Suspense, lazy, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../../api.js';
import { api, type DownstreamApiKeyTrendResponse } from '../../api.js';
import { useToast } from '../../components/Toast.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { readClientTimeZone } from '../helpers/siteAnnouncementPresentation.js';
import {
formatCompactTokens,
formatIso,
@@ -40,10 +41,13 @@ export default function DownstreamKeyDrawer({
const [trendRange, setTrendRange] = useState<Range>(initialRange);
const [trendLoading, setTrendLoading] = useState(false);
const [buckets, setBuckets] = useState<DownstreamKeyTrendBucket[]>([]);
const [trendBucketSeconds, setTrendBucketSeconds] = useState<number>(initialRange === 'all' ? 86400 : 3600);
const viewerTimeZone = useMemo(() => readClientTimeZone(), []);
useEffect(() => {
if (!open) return;
setTrendRange(initialRange);
setTrendBucketSeconds(initialRange === 'all' ? 86400 : 3600);
}, [open, initialRange]);
useEffect(() => {
@@ -72,12 +76,19 @@ export default function DownstreamKeyDrawer({
useEffect(() => {
if (!open || !item?.id) return;
let cancelled = false;
const fallbackBucketSeconds = trendRange === 'all' ? 86400 : 3600;
setBuckets([]);
setTrendBucketSeconds(fallbackBucketSeconds);
setTrendLoading(true);
api.getDownstreamApiKeyTrend(item.id, { range: trendRange })
.then((res: any) => {
const trendParams = trendRange === 'all' && viewerTimeZone
? { range: trendRange, timeZone: viewerTimeZone }
: { range: trendRange };
api.getDownstreamApiKeyTrend(item.id, trendParams)
.then((res: DownstreamApiKeyTrendResponse) => {
if (cancelled) return;
setBuckets(Array.isArray(res?.buckets) ? res.buckets : []);
setBuckets(Array.isArray(res.buckets) ? res.buckets : []);
const nextBucketSeconds = Number(res.bucketSeconds);
setTrendBucketSeconds(Number.isFinite(nextBucketSeconds) && nextBucketSeconds > 0 ? nextBucketSeconds : fallbackBucketSeconds);
})
.catch((err: any) => {
if (cancelled) return;
@@ -90,7 +101,7 @@ export default function DownstreamKeyDrawer({
return () => {
cancelled = true;
};
}, [open, item?.id, trendRange, toast]);
}, [open, item?.id, trendRange, toast, viewerTimeZone]);
if (!presence.shouldRender) return null;
@@ -146,7 +157,7 @@ export default function DownstreamKeyDrawer({
</div>
<Suspense fallback={<TrendChartFallback height={260} />}>
<DownstreamKeyTrendChart buckets={buckets} loading={trendLoading} height={260} />
<DownstreamKeyTrendChart buckets={buckets} bucketSeconds={trendBucketSeconds} loading={trendLoading} height={260} />
</Suspense>
</div>