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:
+26
-4
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user