diff --git a/src/components/usage/RequestEventsDetailsCard.tsx b/src/components/usage/RequestEventsDetailsCard.tsx index 6049af2..5eb18f8 100644 --- a/src/components/usage/RequestEventsDetailsCard.tsx +++ b/src/components/usage/RequestEventsDetailsCard.tsx @@ -12,7 +12,8 @@ import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver import { collectUsageDetails, extractTotalTokens, - normalizeAuthIndex + formatDurationMs, + normalizeAuthIndex, } from '@/utils/usage'; import { downloadBlob } from '@/utils/download'; import styles from '@/pages/UsagePage.module.scss'; @@ -31,6 +32,7 @@ type RequestEventRow = { sourceType: string; authIndex: string; failed: boolean; + latencyMs: number | null; inputTokens: number; outputTokens: number; reasoningTokens: number; @@ -68,7 +70,7 @@ export function RequestEventsDetailsCard({ claudeConfigs, codexConfigs, vertexConfigs, - openaiProviders + openaiProviders, }: RequestEventsDetailsCardProps) { const { t, i18n } = useTranslation(); @@ -91,7 +93,7 @@ export function RequestEventsDetailsCard({ if (!key) return; map.set(key, { name: file.name || key, - type: (file.type || file.provider || '').toString() + type: (file.type || file.provider || '').toString(), }); }); setAuthFileMap(map); @@ -131,7 +133,12 @@ export function RequestEventsDetailsCard({ authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === '' ? '-' : String(authIndexRaw); - const sourceInfo = resolveSourceDisplay(sourceRaw, authIndexRaw, sourceInfoMap, authFileMap); + const sourceInfo = resolveSourceDisplay( + sourceRaw, + authIndexRaw, + sourceInfoMap, + authFileMap + ); const source = sourceInfo.displayName; const sourceType = sourceInfo.type; const model = String(detail.__modelName ?? '').trim() || '-'; @@ -146,6 +153,12 @@ export function RequestEventsDetailsCard({ toNumber(detail.tokens?.total_tokens), extractTotalTokens(detail) ); + const latencyMs = + typeof detail.latency_ms === 'number' && + Number.isFinite(detail.latency_ms) && + detail.latency_ms >= 0 + ? detail.latency_ms + : null; return { id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`, @@ -158,23 +171,26 @@ export function RequestEventsDetailsCard({ sourceType, authIndex, failed: detail.failed === true, + latencyMs, inputTokens, outputTokens, reasoningTokens, cachedTokens, - totalTokens + totalTokens, }; }) .sort((a, b) => b.timestampMs - a.timestampMs); }, [authFileMap, i18n.language, sourceInfoMap, usage]); + const hasLatencyData = useMemo(() => rows.some((row) => row.latencyMs !== null), [rows]); + const modelOptions = useMemo( () => [ { value: ALL_FILTER, label: t('usage_stats.filter_all') }, ...Array.from(new Set(rows.map((row) => row.model))).map((model) => ({ value: model, - label: model - })) + label: model, + })), ], [rows, t] ); @@ -184,8 +200,8 @@ export function RequestEventsDetailsCard({ { value: ALL_FILTER, label: t('usage_stats.filter_all') }, ...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({ value: source, - label: source - })) + label: source, + })), ], [rows, t] ); @@ -195,8 +211,8 @@ export function RequestEventsDetailsCard({ { value: ALL_FILTER, label: t('usage_stats.filter_all') }, ...Array.from(new Set(rows.map((row) => row.authIndex))).map((authIndex) => ({ value: authIndex, - label: authIndex - })) + label: authIndex, + })), ], [rows, t] ); @@ -223,8 +239,10 @@ export function RequestEventsDetailsCard({ const filteredRows = useMemo( () => rows.filter((row) => { - const modelMatched = effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter; - const sourceMatched = effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter; + const modelMatched = + effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter; + const sourceMatched = + effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter; const authIndexMatched = effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter; return modelMatched && sourceMatched && authIndexMatched; @@ -232,10 +250,7 @@ export function RequestEventsDetailsCard({ [effectiveAuthIndexFilter, effectiveModelFilter, effectiveSourceFilter, rows] ); - const renderedRows = useMemo( - () => filteredRows.slice(0, MAX_RENDERED_EVENTS), - [filteredRows] - ); + const renderedRows = useMemo(() => filteredRows.slice(0, MAX_RENDERED_EVENTS), [filteredRows]); const hasActiveFilters = effectiveModelFilter !== ALL_FILTER || @@ -258,11 +273,12 @@ export function RequestEventsDetailsCard({ 'source_raw', 'auth_index', 'result', + ...(hasLatencyData ? ['latency_ms'] : []), 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_tokens', - 'total_tokens' + 'total_tokens', ]; const csvRows = filteredRows.map((row) => @@ -273,11 +289,12 @@ export function RequestEventsDetailsCard({ row.sourceRaw, row.authIndex, row.failed ? 'failed' : 'success', + ...(hasLatencyData ? [row.latencyMs ?? ''] : []), row.inputTokens, row.outputTokens, row.reasoningTokens, row.cachedTokens, - row.totalTokens + row.totalTokens, ] .map((value) => encodeCsv(value)) .join(',') @@ -287,7 +304,7 @@ export function RequestEventsDetailsCard({ const fileTime = new Date().toISOString().replace(/[:.]/g, '-'); downloadBlob({ filename: `usage-events-${fileTime}.csv`, - blob: new Blob([content], { type: 'text/csv;charset=utf-8' }) + blob: new Blob([content], { type: 'text/csv;charset=utf-8' }), }); }; @@ -301,20 +318,21 @@ export function RequestEventsDetailsCard({ source_raw: row.sourceRaw, auth_index: row.authIndex, failed: row.failed, + ...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}), tokens: { input_tokens: row.inputTokens, output_tokens: row.outputTokens, reasoning_tokens: row.reasoningTokens, cached_tokens: row.cachedTokens, - total_tokens: row.totalTokens - } + total_tokens: row.totalTokens, + }, })); const content = JSON.stringify(payload, null, 2); const fileTime = new Date().toISOString().replace(/[:.]/g, '-'); downloadBlob({ filename: `usage-events-${fileTime}.json`, - blob: new Blob([content], { type: 'application/json;charset=utf-8' }) + blob: new Blob([content], { type: 'application/json;charset=utf-8' }), }); }; @@ -412,7 +430,7 @@ export function RequestEventsDetailsCard({ {t('usage_stats.request_events_limit_hint', { shown: MAX_RENDERED_EVENTS, - total: filteredRows.length + total: filteredRows.length, })} )} @@ -427,6 +445,7 @@ export function RequestEventsDetailsCard({ {t('usage_stats.request_events_source')} {t('usage_stats.request_events_auth_index')} {t('usage_stats.request_events_result')} + {hasLatencyData && {t('usage_stats.time')}} {t('usage_stats.input_tokens')} {t('usage_stats.output_tokens')} {t('usage_stats.reasoning_tokens')} @@ -452,11 +471,16 @@ export function RequestEventsDetailsCard({ {row.failed ? t('stats.failure') : t('stats.success')} + {hasLatencyData && {formatDurationMs(row.latencyMs)}} {row.inputTokens.toLocaleString()} {row.outputTokens.toLocaleString()} {row.reasoningTokens.toLocaleString()} diff --git a/src/components/usage/StatCards.tsx b/src/components/usage/StatCards.tsx index 9d80b74..cc87901 100644 --- a/src/components/usage/StatCards.tsx +++ b/src/components/usage/StatCards.tsx @@ -1,15 +1,22 @@ import { useMemo, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Line } from 'react-chartjs-2'; -import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons'; +import { + IconDiamond, + IconDollarSign, + IconSatellite, + IconTimer, + IconTrendingUp, +} from '@/components/ui/icons'; import { formatCompactNumber, + formatDurationMs, formatPerMinuteValue, formatUsd, calculateCost, collectUsageDetails, extractTotalTokens, - type ModelPrice + type ModelPrice, } from '@/utils/usage'; import { sparklineOptions } from '@/utils/usage/chartConfig'; import type { UsagePayload } from './hooks/useUsageData'; @@ -47,11 +54,12 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St const hasPrices = Object.keys(modelPrices).length > 0; - const { tokenBreakdown, rateStats, totalCost } = useMemo(() => { + const { tokenBreakdown, rateStats, totalCost, latencyStats } = useMemo(() => { const empty = { tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 }, rateStats: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 }, - totalCost: 0 + totalCost: 0, + latencyStats: { averageMs: null as number | null, sampleCount: 0 }, }; if (!usage) return empty; @@ -61,6 +69,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St let cachedTokens = 0; let reasoningTokens = 0; let totalCost = 0; + let latencyTotalMs = 0; + let latencySampleCount = 0; const now = nowMs; const windowMinutes = 30; @@ -78,9 +88,22 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St if (typeof tokens.reasoning_tokens === 'number') { reasoningTokens += tokens.reasoning_tokens; } + if ( + typeof detail.latency_ms === 'number' && + Number.isFinite(detail.latency_ms) && + detail.latency_ms >= 0 + ) { + latencyTotalMs += detail.latency_ms; + latencySampleCount += 1; + } const timestamp = detail.__timestampMs ?? 0; - if (hasValidNow && Number.isFinite(timestamp) && timestamp >= windowStart && timestamp <= now) { + if ( + hasValidNow && + Number.isFinite(timestamp) && + timestamp >= windowStart && + timestamp <= now + ) { requestCount += 1; tokenCount += extractTotalTokens(detail); } @@ -98,9 +121,13 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St tpm: tokenCount / denominator, windowMinutes, requestCount, - tokenCount + tokenCount, + }, + totalCost, + latencyStats: { + averageMs: latencySampleCount > 0 ? latencyTotalMs / latencySampleCount : null, + sampleCount: latencySampleCount, }, - totalCost }; }, [hasPrices, modelPrices, nowMs, usage]); @@ -123,9 +150,15 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St {t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)} + {latencyStats.sampleCount > 0 && ( + + {t('usage_stats.avg_time')}:{' '} + {loading ? '-' : formatDurationMs(latencyStats.averageMs)} + + )} ), - trend: sparklines.requests + trend: sparklines.requests, }, { key: 'tokens', @@ -138,14 +171,16 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St meta: ( <> - {t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)} + {t('usage_stats.cached_tokens')}:{' '} + {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)} - {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)} + {t('usage_stats.reasoning_tokens')}:{' '} + {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)} ), - trend: sparklines.tokens + trend: sparklines.tokens, }, { key: 'rpm', @@ -157,10 +192,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St value: loading ? '-' : formatPerMinuteValue(rateStats.rpm), meta: ( - {t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()} + {t('usage_stats.total_requests')}:{' '} + {loading ? '-' : rateStats.requestCount.toLocaleString()} ), - trend: sparklines.rpm + trend: sparklines.rpm, }, { key: 'tpm', @@ -172,10 +208,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St value: loading ? '-' : formatPerMinuteValue(rateStats.tpm), meta: ( - {t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)} + {t('usage_stats.total_tokens')}:{' '} + {loading ? '-' : formatCompactNumber(rateStats.tokenCount)} ), - trend: sparklines.tpm + trend: sparklines.tpm, }, { key: 'cost', @@ -188,7 +225,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St meta: ( <> - {t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)} + {t('usage_stats.total_tokens')}:{' '} + {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)} {!hasPrices && ( @@ -197,8 +235,8 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St )} ), - trend: hasPrices ? sparklines.cost : null - } + trend: hasPrices ? sparklines.cost : null, + }, ]; return ( @@ -211,7 +249,7 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St { '--accent': card.accent, '--accent-soft': card.accentSoft, - '--accent-border': card.accentBorder + '--accent-border': card.accentBorder, } as CSSProperties } > @@ -225,7 +263,11 @@ export function StatCards({ usage, loading, modelPrices, nowMs, sparklines }: St {card.meta &&
{card.meta}
}
{card.trend ? ( - + ) : (
)} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a584ae9..24b6e65 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1023,6 +1023,8 @@ "request_events_source": "Source", "request_events_auth_index": "Auth Index", "request_events_result": "Result", + "time": "Time", + "avg_time": "Avg Time", "request_events_empty_title": "No request events", "request_events_empty_desc": "No request details are available for the selected time range.", "request_events_no_result_title": "No matching events", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 450688b..e837d0e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1026,6 +1026,8 @@ "request_events_source": "Источник", "request_events_auth_index": "Auth Index", "request_events_result": "Результат", + "time": "Время", + "avg_time": "Среднее время", "request_events_empty_title": "События запросов отсутствуют", "request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.", "request_events_no_result_title": "Совпадений не найдено", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index bb2ce2b..79e02af 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1023,6 +1023,8 @@ "request_events_source": "来源", "request_events_auth_index": "认证索引", "request_events_result": "结果", + "time": "耗时", + "avg_time": "平均耗时", "request_events_empty_title": "暂无请求事件", "request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。", "request_events_no_result_title": "没有匹配结果", diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 07abb00..a8a8718 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -39,6 +39,7 @@ export interface UsageDetail { timestamp: string; source: string; auth_index: number; + latency_ms?: number; tokens: { input_tokens: number; output_tokens: number; @@ -66,7 +67,10 @@ export interface ApiStats { failureCount: number; totalTokens: number; totalCost: number; - models: Record; + models: Record< + string, + { requests: number; successCount: number; failureCount: number; tokens: number } + >; } export type UsageTimeRange = '7h' | '24h' | '7d' | 'all'; @@ -77,7 +81,7 @@ const USAGE_ENDPOINT_METHOD_REGEX = /^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s const USAGE_TIME_RANGE_MS: Record, number> = { '7h': 7 * 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000, - '7d': 7 * 24 * 60 * 60 * 1000 + '7d': 7 * 24 * 60 * 60 * 1000, }; const isRecord = (value: unknown): value is Record => @@ -100,17 +104,21 @@ const createUsageSummary = (): UsageSummary => ({ totalRequests: 0, successCount: 0, failureCount: 0, - totalTokens: 0 + totalTokens: 0, }); const toUsageSummaryFields = (summary: UsageSummary) => ({ total_requests: summary.totalRequests, success_count: summary.successCount, failure_count: summary.failureCount, - total_tokens: summary.totalTokens + total_tokens: summary.totalTokens, }); -export function filterUsageByTimeRange(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T { +export function filterUsageByTimeRange( + usageData: T, + range: UsageTimeRange, + nowMs: number = Date.now() +): T { if (range === 'all') { return usageData; } @@ -180,7 +188,7 @@ export function filterUsageByTimeRange(usageData: T, range: UsageTimeRange, n filteredModels[modelName] = { ...modelEntry, ...toUsageSummaryFields(modelSummary), - details: filteredDetails + details: filteredDetails, }; hasModelData = true; @@ -197,7 +205,7 @@ export function filterUsageByTimeRange(usageData: T, range: UsageTimeRange, n filteredApis[apiName] = { ...apiEntry, ...toUsageSummaryFields(apiSummary), - models: filteredModels + models: filteredModels, }; totalSummary.totalRequests += apiSummary.totalRequests; @@ -209,7 +217,7 @@ export function filterUsageByTimeRange(usageData: T, range: UsageTimeRange, n return { ...usageRecord, ...toUsageSummaryFields(totalSummary), - apis: filteredApis + apis: filteredApis, } as T; } @@ -309,7 +317,8 @@ export function normalizeUsageSourceId( value: unknown, masker: (val: string) => string = maskApiKey ): string { - const raw = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value); + const raw = + typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value); const trimmed = raw.trim(); if (!trimmed) return ''; @@ -325,7 +334,10 @@ export function normalizeUsageSourceId( return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`; } -export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: string }): string[] { +export function buildCandidateUsageSourceIds(input: { + apiKey?: string; + prefix?: string; +}): string[] { const result: string[] = []; const prefix = input.prefix?.trim(); @@ -345,7 +357,10 @@ export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: /** * 对使用数据中的敏感字段进行遮罩 */ -export function maskUsageSensitiveValue(value: unknown, masker: (val: string) => string = maskApiKey): string { +export function maskUsageSensitiveValue( + value: unknown, + masker: (val: string) => string = maskApiKey +): string { if (value === null || value === undefined) { return ''; } @@ -357,12 +372,20 @@ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) => let masked = raw; const queryRegex = /([?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/gi; - masked = masked.replace(queryRegex, (_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}`); + masked = masked.replace( + queryRegex, + (_full, prefix, keyName, valuePart) => `${prefix}${keyName}=${masker(valuePart)}` + ); - const headerRegex = /(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi; - masked = masked.replace(headerRegex, (_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}`); + const headerRegex = + /(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/gi; + masked = masked.replace( + headerRegex, + (_full, keyName, separator, valuePart) => `${keyName}${separator}${masker(valuePart)}` + ); - const keyLikeRegex = /(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g; + const keyLikeRegex = + /(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g; masked = masked.replace(keyLikeRegex, (match) => masker(match)); if (masked === raw) { @@ -436,7 +459,7 @@ export function formatUsd(value: number): string { const fixed = num.toFixed(2); const parts = Number(fixed).toLocaleString(undefined, { minimumFractionDigits: 2, - maximumFractionDigits: 2 + maximumFractionDigits: 2, }); return `$${parts}`; } @@ -491,10 +514,12 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] { const timestamp = detailRaw.timestamp; const timestampMs = Date.parse(timestamp); const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {}; + const latencyMs = extractLatencyMs(detailRaw); details.push({ timestamp, source: normalizeSource(detailRaw.source), auth_index: detailRaw.auth_index as unknown as number, + latency_ms: latencyMs ?? undefined, tokens: tokensRaw as unknown as UsageDetail['tokens'], failed: detailRaw.failed === true, __modelName: modelName, @@ -562,10 +587,12 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail const timestamp = detailRaw.timestamp; const timestampMs = Date.parse(timestamp); const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {}; + const latencyMs = extractLatencyMs(detailRaw); details.push({ timestamp, source: normalizeSource(detailRaw.source), auth_index: detailRaw.auth_index as unknown as number, + latency_ms: latencyMs ?? undefined, tokens: tokensRaw as unknown as UsageDetail['tokens'], failed: detailRaw.failed === true, __modelName: modelName, @@ -605,6 +632,42 @@ export function extractTotalTokens(detail: unknown): number { return inputTokens + outputTokens + reasoningTokens + cachedTokens; } +/** + * 从单条明细提取耗时(毫秒) + */ +export function extractLatencyMs(detail: unknown): number | null { + const record = isRecord(detail) ? detail : null; + const rawValue = record?.latency_ms; + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return parsed; +} + +/** + * 格式化耗时显示 + */ +export function formatDurationMs(value: number | null | undefined): string { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return '--'; + } + + if (parsed < 1000) { + return `${Math.round(parsed)} ms`; + } + + const seconds = parsed / 1000; + if (seconds < 10) { + return `${seconds.toFixed(2).replace(/\.?0+$/, '')} s`; + } + if (seconds < 100) { + return `${seconds.toFixed(1).replace(/\.?0+$/, '')} s`; + } + return `${Math.round(seconds)} s`; +} + /** * 计算 token 分类统计 */ @@ -652,7 +715,9 @@ export function calculateRecentPerMinuteRates( details.forEach((detail) => { const timestamp = - typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp); + typeof detail.__timestampMs === 'number' + ? detail.__timestampMs + : Date.parse(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) { return; } @@ -666,7 +731,7 @@ export function calculateRecentPerMinuteRates( tpm: tokenCount / denominator, windowMinutes: effectiveWindow, requestCount, - tokenCount + tokenCount, }; } @@ -694,7 +759,10 @@ export function getModelNamesFromUsage(usageData: unknown): string[] { /** * 计算成本数据 */ -export function calculateCost(detail: UsageDetail, modelPrices: Record): number { +export function calculateCost( + detail: UsageDetail, + modelPrices: Record +): number { const modelName = detail.__modelName || ''; const price = modelPrices[modelName]; if (!price) { @@ -707,7 +775,9 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record 0 ? total : 0; } @@ -724,7 +795,10 @@ export function calculateCost(detail: UsageDetail, modelPrices: Record): number { +export function calculateTotalCost( + usageData: unknown, + modelPrices: Record +): number { const details = collectUsageDetails(usageData); if (!details.length || !Object.keys(modelPrices).length) { return 0; @@ -756,7 +830,11 @@ export function loadModelPrices(): Record { const completionRaw = Number(priceRecord?.completion); const cacheRaw = Number(priceRecord?.cache); - if (!Number.isFinite(promptRaw) && !Number.isFinite(completionRaw) && !Number.isFinite(cacheRaw)) { + if ( + !Number.isFinite(promptRaw) && + !Number.isFinite(completionRaw) && + !Number.isFinite(cacheRaw) + ) { return; } @@ -772,7 +850,7 @@ export function loadModelPrices(): Record { normalized[model] = { prompt, completion, - cache + cache, }; }); return normalized; @@ -798,14 +876,20 @@ export function saveModelPrices(prices: Record): void { /** * 获取 API 统计数据 */ -export function getApiStats(usageData: unknown, modelPrices: Record): ApiStats[] { +export function getApiStats( + usageData: unknown, + modelPrices: Record +): ApiStats[] { const apis = getApisRecord(usageData); if (!apis) return []; const result: ApiStats[] = []; Object.entries(apis).forEach(([endpoint, apiData]) => { if (!isRecord(apiData)) return; - const models: Record = {}; + const models: Record< + string, + { requests: number; successCount: number; failureCount: number; tokens: number } + > = {}; let derivedSuccessCount = 0; let derivedFailureCount = 0; let totalCost = 0; @@ -849,7 +933,7 @@ export function getApiStats(usageData: unknown, modelPrices: Record): Array<{ +export function getModelStats( + usageData: unknown, + modelPrices: Record +): Array<{ model: string; requests: number; successCount: number; @@ -892,7 +979,10 @@ export function getModelStats(usageData: unknown, modelPrices: Record(); + const modelMap = new Map< + string, + { requests: number; successCount: number; failureCount: number; tokens: number; cost: number } + >(); Object.values(apis).forEach((apiData) => { if (!isRecord(apiData)) return; @@ -902,7 +992,13 @@ export function getModelStats(usageData: unknown, modelPrices: Record { if (!isRecord(modelData)) return; - const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 }; + const existing = modelMap.get(modelName) || { + requests: 0, + successCount: 0, + failureCount: 0, + tokens: 0, + cost: 0, + }; existing.requests += Number(modelData.total_requests) || 0; existing.tokens += Number(modelData.total_tokens) || 0; @@ -1012,7 +1108,9 @@ export function buildHourlySeriesByModel( details.forEach((detail) => { const timestamp = - typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp); + typeof detail.__timestampMs === 'number' + ? detail.__timestampMs + : Date.parse(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) { return; } @@ -1069,7 +1167,9 @@ export function buildDailySeriesByModel( details.forEach((detail) => { const timestamp = - typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp); + typeof detail.__timestampMs === 'number' + ? detail.__timestampMs + : Date.parse(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) { return; } @@ -1092,7 +1192,7 @@ export function buildDailySeriesByModel( const labels = Array.from(labelsSet).sort(); const dataByModel = new Map(); valuesByModel.forEach((dayMap, modelName) => { - const series = labels.map(label => dayMap.get(label) || 0); + const series = labels.map((label) => dayMap.get(label) || 0); dataByModel.set(modelName, series); }); @@ -1103,7 +1203,10 @@ export interface ChartDataset { label: string; data: number[]; borderColor: string; - backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient); + backgroundColor: + | string + | CanvasGradient + | ((context: ScriptableContext<'line'>) => string | CanvasGradient); pointBackgroundColor?: string; pointBorderColor?: string; fill: boolean; @@ -1152,7 +1255,11 @@ const withAlpha = (hex: string, alpha: number) => { return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamped})`; }; -const buildAreaGradient = (context: ScriptableContext<'line'>, baseHex: string, fallback: string) => { +const buildAreaGradient = ( + context: ScriptableContext<'line'>, + baseHex: string, + fallback: string +) => { const chart = context.chart; const ctx = chart.ctx; const area = chart.chartArea; @@ -1178,16 +1285,17 @@ export function buildChartData( selectedModels: string[] = [], options: { hourWindowHours?: number } = {} ): ChartData { - const baseSeries = period === 'hour' - ? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours) - : buildDailySeriesByModel(usageData, metric); + const baseSeries = + period === 'hour' + ? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours) + : buildDailySeriesByModel(usageData, metric); const { labels, dataByModel } = baseSeries; // Build "All" series as sum of all models const getAllSeries = (): number[] => { const summed = new Array(labels.length).fill(0); - dataByModel.forEach(values => { + dataByModel.forEach((values) => { values.forEach((value, idx) => { summed[idx] = (summed[idx] || 0) + value; }); @@ -1200,7 +1308,9 @@ export function buildChartData( const datasets: ChartDataset[] = modelsToShow.map((model, index) => { const isAll = model === 'all'; - const data = isAll ? getAllSeries() : (dataByModel.get(model) || new Array(labels.length).fill(0)); + const data = isAll + ? getAllSeries() + : dataByModel.get(model) || new Array(labels.length).fill(0); const colorIndex = index % CHART_COLORS.length; const style = CHART_COLORS[colorIndex]; const shouldFill = modelsToShow.length === 1 || (isAll && modelsToShow.length > 1); @@ -1215,7 +1325,7 @@ export function buildChartData( pointBackgroundColor: style.borderColor, pointBorderColor: style.borderColor, fill: shouldFill, - tension: 0.35 + tension: 0.35, }; }); @@ -1283,8 +1393,15 @@ export function calculateStatusBarData( // Filter and bucket the usage details usageDetails.forEach((detail) => { const timestamp = - typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp); - if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) { + typeof detail.__timestampMs === 'number' + ? detail.__timestampMs + : Date.parse(detail.timestamp); + if ( + !Number.isFinite(timestamp) || + timestamp <= 0 || + timestamp < windowStart || + timestamp > now + ) { return; } @@ -1346,7 +1463,7 @@ export function calculateStatusBarData( blockDetails, successRate, totalSuccess, - totalFailure + totalFailure, }; } @@ -1364,9 +1481,7 @@ export interface ServiceHealthData { cols: number; } -export function calculateServiceHealthData( - usageDetails: UsageDetail[] -): ServiceHealthData { +export function calculateServiceHealthData(usageDetails: UsageDetail[]): ServiceHealthData { const ROWS = 7; const COLS = 96; const BLOCK_COUNT = ROWS * COLS; // 672 @@ -1386,8 +1501,15 @@ export function calculateServiceHealthData( usageDetails.forEach((detail) => { const timestamp = - typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp); - if (!Number.isFinite(timestamp) || timestamp <= 0 || timestamp < windowStart || timestamp > now) { + typeof detail.__timestampMs === 'number' + ? detail.__timestampMs + : Date.parse(detail.timestamp); + if ( + !Number.isFinite(timestamp) || + timestamp <= 0 || + timestamp < windowStart || + timestamp > now + ) { return; } @@ -1444,7 +1566,10 @@ export function calculateServiceHealthData( }; } -export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats { +export function computeKeyStats( + usageData: unknown, + masker: (val: string) => string = maskApiKey +): KeyStats { const apis = getApisRecord(usageData); if (!apis) { return { bySource: {}, byAuthIndex: {} }; @@ -1499,7 +1624,7 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str return { bySource: sourceStats, - byAuthIndex: authIndexStats + byAuthIndex: authIndexStats, }; } @@ -1586,7 +1711,9 @@ export function buildHourlyTokenBreakdown( details.forEach((detail) => { const timestamp = - typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp); + typeof detail.__timestampMs === 'number' + ? detail.__timestampMs + : Date.parse(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const normalized = new Date(timestamp); normalized.setMinutes(0, 0, 0); @@ -1601,9 +1728,10 @@ export function buildHourlyTokenBreakdown( const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0; const cached = Math.max( typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0, - typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0, + typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0 ); - const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0; + const reasoning = + typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0; dataByCategory.input[bucketIndex] += input; dataByCategory.output[bucketIndex] += output; @@ -1625,7 +1753,9 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri details.forEach((detail) => { const timestamp = - typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp); + typeof detail.__timestampMs === 'number' + ? detail.__timestampMs + : Date.parse(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const dayLabel = formatDayLabel(new Date(timestamp)); if (!dayLabel) return; @@ -1639,9 +1769,10 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0; const cached = Math.max( typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0, - typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0, + typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0 ); - const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0; + const reasoning = + typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0; dayMap[dayLabel].input += input; dayMap[dayLabel].output += output; @@ -1699,7 +1830,9 @@ export function buildHourlyCostSeries( details.forEach((detail) => { const timestamp = - typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp); + typeof detail.__timestampMs === 'number' + ? detail.__timestampMs + : Date.parse(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const normalized = new Date(timestamp); normalized.setMinutes(0, 0, 0); @@ -1732,7 +1865,9 @@ export function buildDailyCostSeries( details.forEach((detail) => { const timestamp = - typeof detail.__timestampMs === 'number' ? detail.__timestampMs : Date.parse(detail.timestamp); + typeof detail.__timestampMs === 'number' + ? detail.__timestampMs + : Date.parse(detail.timestamp); if (!Number.isFinite(timestamp) || timestamp <= 0) return; const dayLabel = formatDayLabel(new Date(timestamp)); if (!dayLabel) return;