From 287c5aafd68041791ac1d63a3d94dee538e5c37d Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 6 Apr 2026 08:46:40 +0800 Subject: [PATCH] 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 --- .../routes/api/downstreamApiKeys.test.ts | 71 +++- src/server/routes/api/downstreamApiKeys.ts | 99 ++---- .../downstreamApiKeyTrendService.test.ts | 171 +++++++++ .../services/downstreamApiKeyTrendService.ts | 334 ++++++++++++++++++ src/web/api.ts | 30 +- .../charts/DownstreamKeyTrendChart.test.tsx | 128 +++++++ .../charts/DownstreamKeyTrendChart.tsx | 31 +- .../downstream-keys/DownstreamKeyDrawer.tsx | 25 +- 8 files changed, 800 insertions(+), 89 deletions(-) create mode 100644 src/server/services/downstreamApiKeyTrendService.test.ts create mode 100644 src/server/services/downstreamApiKeyTrendService.ts create mode 100644 src/web/components/charts/DownstreamKeyTrendChart.test.tsx diff --git a/src/server/routes/api/downstreamApiKeys.test.ts b/src/server/routes/api/downstreamApiKeys.test.ts index f3fef57..249b1a4 100644 --- a/src/server/routes/api/downstreamApiKeys.test.ts +++ b/src/server/routes/api/downstreamApiKeys.test.ts @@ -41,11 +41,11 @@ describe('downstream api keys routes', () => { }); it('builds postgres trend buckets by casting text timestamps before date_trunc', async () => { - const routesModule = await import('./downstreamApiKeys.js') as Record; + const trendServiceModule = await import('../../services/downstreamApiKeyTrendService.js') as Record; - expect(typeof routesModule.buildBucketTsExpressionForDialect).toBe('function'); + expect(typeof trendServiceModule.buildBucketTsExpressionForDialect).toBe('function'); - const expression = routesModule.buildBucketTsExpressionForDialect('postgres', schema.proxyLogs.createdAt, 3600); + const expression = trendServiceModule.buildBucketTsExpressionForDialect('postgres', schema.proxyLogs.createdAt, 3600); const rendered = new PgDialect().sqlToQuery(expression).sql; expect(rendered).toContain('date_trunc'); @@ -787,6 +787,71 @@ describe('downstream api keys routes', () => { expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 900)).toBe(true); }); + it('groups all-range trend buckets by local day boundaries', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values({ + name: 'local-day-key', + key: 'sk-local-day-key-001', + enabled: true, + description: 'local day trend', + usedCost: 0, + usedRequests: 0, + }).returning().get(); + + await db.insert(schema.proxyLogs).values([ + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 100, + estimatedCost: 0.01, + createdAt: '2026-04-05T23:30:00.000Z', + }, + { + downstreamApiKeyId: inserted.id, + status: 'failed', + totalTokens: 200, + estimatedCost: 0.02, + createdAt: '2026-04-06T01:00:00.000Z', + }, + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 300, + estimatedCost: 0.03, + createdAt: '2026-04-06T16:30:00.000Z', + }, + ]).run(); + + const trendRes = await app.inject({ + method: 'GET', + url: `/api/downstream-keys/${inserted.id}/trend?range=all&timeZone=${encodeURIComponent('Asia/Shanghai')}`, + }); + + expect(trendRes.statusCode).toBe(200); + expect(trendRes.json()).toMatchObject({ + success: true, + range: 'all', + timeZone: 'Asia/Shanghai', + buckets: [ + { + startUtc: '2026-04-05T16:00:00.000Z', + totalRequests: 2, + successRequests: 1, + failedRequests: 1, + totalTokens: 300, + totalCost: 0.03, + }, + { + startUtc: '2026-04-06T16:00:00.000Z', + totalRequests: 1, + successRequests: 1, + failedRequests: 0, + totalTokens: 300, + totalCost: 0.03, + }, + ], + }); + }); + it('rejects unknown route ids and site ids in downstream policy payloads', async () => { const createRes = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/downstreamApiKeys.ts b/src/server/routes/api/downstreamApiKeys.ts index 53b1d3b..1894975 100644 --- a/src/server/routes/api/downstreamApiKeys.ts +++ b/src/server/routes/api/downstreamApiKeys.ts @@ -1,5 +1,5 @@ import { FastifyInstance } from 'fastify'; -import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm'; +import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'; import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js'; import { insertAndGetById } from '../../db/insertHelpers.js'; import { @@ -9,8 +9,15 @@ import { toDownstreamApiKeyPolicyView, toPersistenceJson, } from '../../services/downstreamApiKeyService.js'; -import type { DownstreamExcludedCredentialRef } from '../../services/downstreamPolicyTypes.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import type { DownstreamExcludedCredentialRef } from '../../services/downstreamPolicyTypes.js'; +import { + readDownstreamApiKeyTrendBuckets, + resolveDownstreamTrendBucketSeconds, + resolveDownstreamTrendRangeSinceUtc, + resolveDownstreamTrendTimeZone, + type DownstreamKeyTrendRange, +} from '../../services/downstreamApiKeyTrendService.js'; import { parseDownstreamApiKeyBatchPayload, parseDownstreamApiKeyPayload, @@ -78,7 +85,7 @@ function normalizeBatchIds(raw: unknown): number[] { return ids; } -type DownstreamKeyRange = '24h' | '7d' | 'all'; +type DownstreamKeyRange = DownstreamKeyTrendRange; type DownstreamKeyStatus = 'all' | 'enabled' | 'disabled'; function normalizeDownstreamKeyRange(raw: unknown): DownstreamKeyRange { @@ -124,37 +131,7 @@ function normalizeTagMatchMode(raw: unknown): 'any' | 'all' { } function resolveRangeSinceUtc(range: DownstreamKeyRange): string | null { - const nowTs = Date.now(); - if (range === '24h') return formatUtcSqlDateTime(new Date(nowTs - 24 * 60 * 60 * 1000)); - if (range === '7d') return formatUtcSqlDateTime(new Date(nowTs - 7 * 24 * 60 * 60 * 1000)); - return null; -} - -function resolveBucketSeconds(range: DownstreamKeyRange): number { - return range === 'all' ? 86400 : 3600; -} - -export function buildBucketTsExpressionForDialect( - dialect: 'sqlite' | 'mysql' | 'postgres', - createdAtColumn: SQLWrapper, - bucketSeconds: number, -) { - if (dialect === 'mysql') { - return sql`floor(unix_timestamp(${createdAtColumn}) / ${bucketSeconds}) * ${bucketSeconds}`; - } - if (dialect === 'postgres') { - const createdAtTimestamp = sql`cast(${createdAtColumn} as timestamp)`; - if (bucketSeconds === 86400) { - return sql`extract(epoch from date_trunc('day', ${createdAtTimestamp}))::bigint`; - } - return sql`extract(epoch from date_trunc('hour', ${createdAtTimestamp}))::bigint`; - } - // sqlite - return sql`cast(cast(strftime('%s', ${createdAtColumn}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`; -} - -function resolveBucketTsExpression(bucketSeconds: number) { - return buildBucketTsExpressionForDialect(runtimeDbDialect, schema.proxyLogs.createdAt, bucketSeconds); + return resolveDownstreamTrendRangeSinceUtc(range); } async function validatePolicyReferences(input: { @@ -447,7 +424,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return { success: true, item, usage: { last24h, last7d, all } }; }); - app.get<{ Params: { id: string }; Querystring: { range?: string } }>('/api/downstream-keys/:id/trend', async (request, reply) => { + app.get<{ Params: { id: string }; Querystring: { range?: string; timeZone?: string } }>('/api/downstream-keys/:id/trend', async (request, reply) => { const id = parseRouteId(request.params.id); if (!id) { return reply.code(400).send({ success: false, message: 'id 无效' }); @@ -461,49 +438,29 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { const columnReady = await hasProxyLogDownstreamApiKeyIdColumn(); if (!columnReady) { - return { success: true, range, item: { id: item.id, name: item.name }, buckets: [] }; + return { + success: true, + range, + item: { id: item.id, name: item.name }, + bucketSeconds: resolveDownstreamTrendBucketSeconds(range), + timeZone: resolveDownstreamTrendTimeZone(request.query?.timeZone), + buckets: [], + }; } - const bucketSeconds = resolveBucketSeconds(range); - const bucketTs = resolveBucketTsExpression(bucketSeconds); - const sinceUtc = resolveRangeSinceUtc(range); - - const rows = await db.select({ - bucketTs, - totalRequests: sql`count(*)`, - successRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, - failedRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, - totalTokens: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, - totalCost: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, - }) - .from(schema.proxyLogs) - .where(and( - eq(schema.proxyLogs.downstreamApiKeyId, id), - ...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []), - )) - .groupBy(bucketTs) - .orderBy(bucketTs) - .all(); + const trend = await readDownstreamApiKeyTrendBuckets({ + downstreamApiKeyId: id, + range, + timeZone: request.query?.timeZone, + }); return { success: true, range, item: { id: item.id, name: item.name }, - bucketSeconds, - buckets: rows.map((row: any) => { - const tsSeconds = Number(row.bucketTs || 0); - const totalRequests = Number(row.totalRequests || 0); - const successRequests = Number(row.successRequests || 0); - return { - startUtc: tsSeconds > 0 ? new Date(tsSeconds * 1000).toISOString() : null, - totalRequests, - successRequests, - failedRequests: Number(row.failedRequests || 0), - successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null, - totalTokens: Number(row.totalTokens || 0), - totalCost: Math.round(Number(row.totalCost || 0) * 1_000_000) / 1_000_000, - }; - }), + bucketSeconds: trend.bucketSeconds, + timeZone: trend.timeZone, + buckets: trend.buckets, }; }); diff --git a/src/server/services/downstreamApiKeyTrendService.test.ts b/src/server/services/downstreamApiKeyTrendService.test.ts new file mode 100644 index 0000000..eed4915 --- /dev/null +++ b/src/server/services/downstreamApiKeyTrendService.test.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type DbModule = typeof import('../db/index.js'); +type TrendServiceModule = typeof import('./downstreamApiKeyTrendService.js'); + +const INSERT_BATCH_SIZE = 100; + +describe('downstreamApiKeyTrendService', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let closeDbConnections: DbModule['closeDbConnections']; + let trendService: TrendServiceModule; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-downstream-trend-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const trendServiceModule = await import('./downstreamApiKeyTrendService.js'); + + db = dbModule.db; + schema = dbModule.schema; + closeDbConnections = dbModule.closeDbConnections; + trendService = trendServiceModule; + }); + + beforeEach(async () => { + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.downstreamApiKeys).run(); + }); + + afterAll(async () => { + await closeDbConnections(); + delete process.env.DATA_DIR; + }); + + it('reads all-range buckets across cursor pages when many rows share the same createdAt', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values({ + name: 'cursor-key', + key: 'sk-cursor-key-001', + enabled: true, + }).returning().get(); + + const sharedCreatedAt = '2026-04-05T00:15:00.000Z'; + const sharedRows = Array.from({ length: 5_001 }, () => ({ + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 1, + estimatedCost: 0.001, + createdAt: sharedCreatedAt, + })); + + for (let index = 0; index < sharedRows.length; index += INSERT_BATCH_SIZE) { + await db.insert(schema.proxyLogs).values(sharedRows.slice(index, index + INSERT_BATCH_SIZE)).run(); + } + + await db.insert(schema.proxyLogs).values({ + downstreamApiKeyId: inserted.id, + status: 'failed', + totalTokens: 2, + estimatedCost: 0.002, + createdAt: '2026-04-06T00:30:00.000Z', + }).run(); + + const trend = await trendService.readDownstreamApiKeyTrendBuckets({ + downstreamApiKeyId: inserted.id, + range: 'all', + timeZone: 'UTC', + }); + + expect(trend.bucketSeconds).toBe(86400); + expect(trend.timeZone).toBe('UTC'); + expect(trend.buckets).toHaveLength(2); + expect(trend.buckets[0]).toMatchObject({ + startUtc: '2026-04-05T00:00:00.000Z', + totalRequests: 5_001, + successRequests: 5_001, + failedRequests: 0, + totalTokens: 5_001, + }); + expect(trend.buckets[0]?.totalCost).toBeCloseTo(5.001, 6); + expect(trend.buckets[1]).toMatchObject({ + startUtc: '2026-04-06T00:00:00.000Z', + totalRequests: 1, + successRequests: 0, + failedRequests: 1, + totalTokens: 2, + }); + expect(trend.buckets[1]?.totalCost).toBeCloseTo(0.002, 6); + }); + + it('normalizes trend time zones consistently for explicit and invalid values', () => { + const fallback = trendService.resolveDownstreamTrendTimeZone(); + + expect(trendService.resolveDownstreamTrendTimeZone('UTC')).toBe('UTC'); + expect(trendService.resolveDownstreamTrendTimeZone('Invalid/Zone')).toBe(fallback); + expect(trendService.resolveDownstreamTrendTimeZone(' ')).toBe(fallback); + }); + + it('uses local hour buckets for windowed ranges in half-hour offset time zones', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values({ + name: 'windowed-local-hour-key', + key: 'sk-windowed-local-hour-key-001', + enabled: true, + }).returning().get(); + + const baseHour = new Date(Date.now() - 2 * 60 * 60 * 1000); + baseHour.setUTCMinutes(0, 0, 0); + const firstCreatedAt = new Date(baseHour); + firstCreatedAt.setUTCMinutes(10, 0, 0); + const secondCreatedAt = new Date(baseHour); + secondCreatedAt.setUTCMinutes(40, 0, 0); + + await db.insert(schema.proxyLogs).values([ + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 10, + estimatedCost: 0.01, + createdAt: firstCreatedAt.toISOString(), + }, + { + downstreamApiKeyId: inserted.id, + status: 'failed', + totalTokens: 20, + estimatedCost: 0.02, + createdAt: secondCreatedAt.toISOString(), + }, + ]).run(); + + const trend = await trendService.readDownstreamApiKeyTrendBuckets({ + downstreamApiKeyId: inserted.id, + range: '24h', + timeZone: 'Asia/Kolkata', + }); + + expect(trend.bucketSeconds).toBe(3600); + expect(trend.timeZone).toBe('Asia/Kolkata'); + expect(trend.buckets).toMatchObject([ + { + startUtc: expectedFixedOffsetHourBucketStartUtc(firstCreatedAt.toISOString(), 330), + totalRequests: 1, + successRequests: 1, + failedRequests: 0, + totalTokens: 10, + totalCost: 0.01, + }, + { + startUtc: expectedFixedOffsetHourBucketStartUtc(secondCreatedAt.toISOString(), 330), + totalRequests: 1, + successRequests: 0, + failedRequests: 1, + totalTokens: 20, + totalCost: 0.02, + }, + ]); + }); +}); + +function expectedFixedOffsetHourBucketStartUtc(raw: string, offsetMinutes: number): string { + const parsed = new Date(raw); + const localMs = parsed.getTime() + offsetMinutes * 60_000; + const localBucketStart = new Date(localMs); + localBucketStart.setUTCMinutes(0, 0, 0); + return new Date(localBucketStart.getTime() - offsetMinutes * 60_000).toISOString(); +} diff --git a/src/server/services/downstreamApiKeyTrendService.ts b/src/server/services/downstreamApiKeyTrendService.ts new file mode 100644 index 0000000..af3c2ad --- /dev/null +++ b/src/server/services/downstreamApiKeyTrendService.ts @@ -0,0 +1,334 @@ +import { and, asc, eq, sql, type SQL, type SQLWrapper } from 'drizzle-orm'; +import { db, runtimeDbDialect, schema } from '../db/index.js'; +import { + formatUtcSqlDateTime, + getResolvedTimeZone, + parseStoredUtcDateTime, + type StoredUtcDateTimeInput, +} from './localTimeService.js'; + +export type DownstreamKeyTrendRange = '24h' | '7d' | 'all'; + +export type DownstreamKeyTrendBucket = { + startUtc: string | null; + totalRequests: number; + successRequests: number; + failedRequests: number; + successRate: number | null; + totalTokens: number; + totalCost: number; +}; + +type DownstreamTrendLogRow = { + id: number; + createdAt: StoredUtcDateTimeInput; + status: string | null; + totalTokens: number | null; + totalCost: number | null; +}; + +type DownstreamTrendBucketAccumulator = { + startUtc: string; + totalRequests: number; + successRequests: number; + failedRequests: number; + totalTokens: number; + totalCost: number; +}; + +type TimeZoneDateTimeParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +}; + +type DownstreamTrendCursor = { + createdAt: StoredUtcDateTimeInput; + id: number; +}; + +const ALL_RANGE_CHUNK_SIZE = 5_000; + +export function resolveDownstreamTrendRangeSinceUtc(range: DownstreamKeyTrendRange): string | null { + const nowTs = Date.now(); + if (range === '24h') return formatUtcSqlDateTime(new Date(nowTs - 24 * 60 * 60 * 1000)); + if (range === '7d') return formatUtcSqlDateTime(new Date(nowTs - 7 * 24 * 60 * 60 * 1000)); + return null; +} + +export function resolveDownstreamTrendBucketSeconds(range: DownstreamKeyTrendRange): number { + return range === 'all' ? 86400 : 3600; +} + +export function buildBucketTsExpressionForDialect( + dialect: 'sqlite' | 'mysql' | 'postgres', + createdAtColumn: SQLWrapper, + bucketSeconds: number, +) { + if (dialect === 'mysql') { + return sql`floor(unix_timestamp(${createdAtColumn}) / ${bucketSeconds}) * ${bucketSeconds}`; + } + if (dialect === 'postgres') { + const createdAtTimestamp = sql`cast(${createdAtColumn} as timestamp)`; + if (bucketSeconds === 86400) { + return sql`extract(epoch from date_trunc('day', ${createdAtTimestamp}))::bigint`; + } + return sql`extract(epoch from date_trunc('hour', ${createdAtTimestamp}))::bigint`; + } + return sql`cast(cast(strftime('%s', ${createdAtColumn}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`; +} + +function resolveBucketTsExpression(bucketSeconds: number) { + return buildBucketTsExpressionForDialect(runtimeDbDialect, schema.proxyLogs.createdAt, bucketSeconds); +} + +export function resolveDownstreamTrendTimeZone(raw?: string | null): string { + const text = String(raw || '').trim(); + if (text) { + try { + new Intl.DateTimeFormat('en-CA', { timeZone: text }).format(new Date()); + return text; + } catch { + // fall through to server default + } + } + return getResolvedTimeZone(); +} + +function formatDateTimePartsInTimeZone(value: Date, timeZone: string): TimeZoneDateTimeParts | null { + if (Number.isNaN(value.getTime())) return null; + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts = formatter.formatToParts(value); + const lookup = new Map(parts.map((part) => [part.type, part.value])); + const year = Number(lookup.get('year')); + const month = Number(lookup.get('month')); + const day = Number(lookup.get('day')); + const hour = Number(lookup.get('hour')); + const minute = Number(lookup.get('minute')); + const second = Number(lookup.get('second')); + if (![year, month, day, hour, minute, second].every(Number.isFinite)) return null; + return { year, month, day, hour, minute, second }; +} + +function toUtcComparableValue(parts: TimeZoneDateTimeParts): number { + return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0); +} + +function sameDateTimeParts(left: TimeZoneDateTimeParts, right: TimeZoneDateTimeParts): boolean { + return left.year === right.year + && left.month === right.month + && left.day === right.day + && left.hour === right.hour + && left.minute === right.minute + && left.second === right.second; +} + +function resolveTargetBucketParts(parts: TimeZoneDateTimeParts, bucketSeconds: number): TimeZoneDateTimeParts { + if (bucketSeconds >= 86400) { + return { ...parts, hour: 0, minute: 0, second: 0 }; + } + return { ...parts, minute: 0, second: 0 }; +} + +function convertZonedPartsToUtcDate(parts: TimeZoneDateTimeParts, timeZone: string): Date | null { + let candidateMs = toUtcComparableValue(parts); + // Iterate toward the UTC instant that renders back to the requested local wall time. + // Rare DST gaps/ambiguities may never converge; callers skip null buckets safely. + for (let attempt = 0; attempt < 4; attempt += 1) { + const candidate = new Date(candidateMs); + const rendered = formatDateTimePartsInTimeZone(candidate, timeZone); + if (!rendered) return null; + if (sameDateTimeParts(rendered, parts)) return candidate; + candidateMs += toUtcComparableValue(parts) - toUtcComparableValue(rendered); + } + const finalCandidate = new Date(candidateMs); + const finalRendered = formatDateTimePartsInTimeZone(finalCandidate, timeZone); + return finalRendered && sameDateTimeParts(finalRendered, parts) ? finalCandidate : null; +} + +function resolveLocalBucketStartUtc( + raw: StoredUtcDateTimeInput, + bucketSeconds: number, + timeZone: string, +): string | null { + const parsed = parseStoredUtcDateTime(raw); + if (!parsed) return null; + const localParts = formatDateTimePartsInTimeZone(parsed, timeZone); + if (!localParts) return null; + const targetParts = resolveTargetBucketParts(localParts, bucketSeconds); + const bucketStart = convertZonedPartsToUtcDate(targetParts, timeZone); + return bucketStart ? bucketStart.toISOString() : null; +} + +function accumulateTrendRows( + accumulator: Map, + rows: DownstreamTrendLogRow[], + bucketSeconds: number, + timeZone: string, +) { + for (const row of rows) { + const startUtc = resolveLocalBucketStartUtc(row.createdAt, bucketSeconds, timeZone); + if (!startUtc) continue; + const bucket = accumulator.get(startUtc) || { + startUtc, + totalRequests: 0, + successRequests: 0, + failedRequests: 0, + totalTokens: 0, + totalCost: 0, + }; + const isSuccess = (row.status || '').trim().toLowerCase() === 'success'; + bucket.totalRequests += 1; + bucket.successRequests += isSuccess ? 1 : 0; + bucket.failedRequests += isSuccess ? 0 : 1; + bucket.totalTokens += Number(row.totalTokens || 0); + bucket.totalCost += Number(row.totalCost || 0); + accumulator.set(startUtc, bucket); + } +} + +function finalizeTrendBuckets(accumulator: Map): DownstreamKeyTrendBucket[] { + return Array.from(accumulator.values()) + .sort((left, right) => left.startUtc.localeCompare(right.startUtc)) + .map((bucket) => ({ + startUtc: bucket.startUtc, + totalRequests: bucket.totalRequests, + successRequests: bucket.successRequests, + failedRequests: bucket.failedRequests, + successRate: bucket.totalRequests > 0 ? Math.round((bucket.successRequests / bucket.totalRequests) * 1000) / 10 : null, + totalTokens: bucket.totalTokens, + totalCost: Math.round(bucket.totalCost * 1_000_000) / 1_000_000, + })); +} + +function buildTrendCursorClause(cursor: DownstreamTrendCursor): SQL { + return sql`( + ${schema.proxyLogs.createdAt} > ${cursor.createdAt} + or (${schema.proxyLogs.createdAt} = ${cursor.createdAt} and ${schema.proxyLogs.id} > ${cursor.id}) + )`; +} + +async function readAllRangeTrendBuckets( + downstreamApiKeyId: number, + bucketSeconds: number, + timeZone: string, + sinceUtc: string | null, +): Promise { + const accumulator = new Map(); + let cursor: DownstreamTrendCursor | null = null; + + for (;;) { + const whereClauses: SQL[] = [eq(schema.proxyLogs.downstreamApiKeyId, downstreamApiKeyId)]; + if (sinceUtc) { + whereClauses.push(sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`); + } + if (cursor) { + whereClauses.push(buildTrendCursorClause(cursor)); + } + + const rows = await db.select({ + id: schema.proxyLogs.id, + createdAt: schema.proxyLogs.createdAt, + status: schema.proxyLogs.status, + totalTokens: schema.proxyLogs.totalTokens, + totalCost: schema.proxyLogs.estimatedCost, + }) + .from(schema.proxyLogs) + .where(and(...whereClauses)) + .orderBy(asc(schema.proxyLogs.createdAt), asc(schema.proxyLogs.id)) + .limit(ALL_RANGE_CHUNK_SIZE) + .all(); + + if (rows.length <= 0) break; + accumulateTrendRows(accumulator, rows, bucketSeconds, timeZone); + const lastRow = rows.at(-1); + if (rows.length < ALL_RANGE_CHUNK_SIZE || !lastRow) break; + cursor = { + createdAt: lastRow.createdAt, + id: lastRow.id, + }; + } + + return finalizeTrendBuckets(accumulator); +} + +async function readWindowedTrendBuckets( + downstreamApiKeyId: number, + bucketSeconds: number, + sinceUtc: string | null, + timeZone: string, +): Promise { + if (timeZone.toUpperCase() !== 'UTC') { + return readAllRangeTrendBuckets(downstreamApiKeyId, bucketSeconds, timeZone, sinceUtc); + } + + const bucketTs = resolveBucketTsExpression(bucketSeconds); + const whereClauses: SQL[] = [eq(schema.proxyLogs.downstreamApiKeyId, downstreamApiKeyId)]; + if (sinceUtc) { + whereClauses.push(sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`); + } + + const rows = await db.select({ + bucketTs, + totalRequests: sql`count(*)`, + successRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, + failedRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, + totalTokens: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + totalCost: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .where(and(...whereClauses)) + .groupBy(bucketTs) + .orderBy(bucketTs) + .all(); + + return rows.map((row: any) => { + const tsSeconds = Number(row.bucketTs || 0); + const totalRequests = Number(row.totalRequests || 0); + const successRequests = Number(row.successRequests || 0); + return { + startUtc: tsSeconds > 0 ? new Date(tsSeconds * 1000).toISOString() : null, + totalRequests, + successRequests, + failedRequests: Number(row.failedRequests || 0), + successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null, + totalTokens: Number(row.totalTokens || 0), + totalCost: Math.round(Number(row.totalCost || 0) * 1_000_000) / 1_000_000, + }; + }); +} + +export async function readDownstreamApiKeyTrendBuckets(input: { + downstreamApiKeyId: number; + range: DownstreamKeyTrendRange; + timeZone?: string | null; +}): Promise<{ + bucketSeconds: number; + timeZone: string; + buckets: DownstreamKeyTrendBucket[]; +}> { + const bucketSeconds = resolveDownstreamTrendBucketSeconds(input.range); + const sinceUtc = resolveDownstreamTrendRangeSinceUtc(input.range); + const timeZone = resolveDownstreamTrendTimeZone(input.timeZone); + const buckets = input.range === 'all' + ? await readAllRangeTrendBuckets(input.downstreamApiKeyId, bucketSeconds, timeZone, sinceUtc) + : await readWindowedTrendBuckets(input.downstreamApiKeyId, bucketSeconds, sinceUtc, timeZone); + + return { + bucketSeconds, + timeZone, + buckets, + }; +} diff --git a/src/web/api.ts b/src/web/api.ts index 13c5b4a..afce9fb 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -134,12 +134,12 @@ async function fetchAuthenticatedResponse(url: string, options: RequestOptions = } } -async function request(url: string, options: RequestOptions = {}) { +async function request(url: string, options: RequestOptions = {}): Promise { const res = await fetchAuthenticatedResponse(url, options); if (!res.ok) { throw new Error(await extractResponseErrorMessage(res)); } - return res.json(); + return res.json() as Promise; } 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(`/api/downstream-keys/${id}/trend${buildQueryString(params)}`), exportBackup: (type: 'all' | 'accounts' | 'preferences' = 'all') => request(`/api/settings/backup/export?type=${encodeURIComponent(type)}`), importBackup: (data: any) => diff --git a/src/web/components/charts/DownstreamKeyTrendChart.test.tsx b/src/web/components/charts/DownstreamKeyTrendChart.test.tsx new file mode 100644 index 0000000..bc34bbd --- /dev/null +++ b/src/web/components/charts/DownstreamKeyTrendChart.test.tsx @@ -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) => { + 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, 'getComputedStyle'); + Reflect.deleteProperty(globalThis as typeof globalThis & Record, '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( + , + ); + })).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; + }; + }; + }; + }; + + 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( + , + ); + })).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(); + }); +}); diff --git a/src/web/components/charts/DownstreamKeyTrendChart.tsx b/src/web/components/charts/DownstreamKeyTrendChart.tsx index fe88470..4c01404 100644 --- a/src/web/components/charts/DownstreamKeyTrendChart.tsx +++ b/src/web/components/charts/DownstreamKeyTrendChart.tsx @@ -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(datum?.date || '') }, + title: { value: (datum: Record) => String(datum?.tooltipDate || datum?.date || '') }, content: [ { key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value', diff --git a/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx b/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx index 61552d4..a3114d1 100644 --- a/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx +++ b/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx @@ -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(initialRange); const [trendLoading, setTrendLoading] = useState(false); const [buckets, setBuckets] = useState([]); + const [trendBucketSeconds, setTrendBucketSeconds] = useState(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({ }> - +