diff --git a/src/features/monitoring/accountOverviewState.test.ts b/src/features/monitoring/accountOverviewState.test.ts index 03f5413ca..0f9e3e663 100644 --- a/src/features/monitoring/accountOverviewState.test.ts +++ b/src/features/monitoring/accountOverviewState.test.ts @@ -69,8 +69,13 @@ const createEventRow = (overrides: Partial = {}): Monitoring channelHost: overrides.channelHost ?? 'localhost', channelDisabled: overrides.channelDisabled ?? false, failed: overrides.failed ?? false, + requestCount: overrides.requestCount ?? 1, + successCalls: overrides.successCalls ?? (overrides.failed ? 0 : 1), + failureCalls: overrides.failureCalls ?? (overrides.failed ? 1 : 0), statsIncluded: overrides.statsIncluded ?? true, latencyMs: overrides.latencyMs ?? 120, + latencySumMs: overrides.latencySumMs ?? (overrides.latencyMs ?? 120), + latencyCount: overrides.latencyCount ?? 1, inputTokens: overrides.inputTokens ?? 10, outputTokens: overrides.outputTokens ?? 5, reasoningTokens: overrides.reasoningTokens ?? 0, diff --git a/src/features/monitoring/hooks/useMonitoringData.test.ts b/src/features/monitoring/hooks/useMonitoringData.test.ts index f6748405e..a108cb2fb 100644 --- a/src/features/monitoring/hooks/useMonitoringData.test.ts +++ b/src/features/monitoring/hooks/useMonitoringData.test.ts @@ -3,6 +3,7 @@ import { buildAccountRows, buildApiKeyRows, buildApiKeyDisplayMap, + buildMonitoringFilterFacetsFromSummary, buildRangeFilteredRows, buildMonitoringAuthMetaMap, type MonitoringEventRow, @@ -40,8 +41,13 @@ const createMonitoringEventRow = ( channelHost: overrides.channelHost ?? 'example.com', channelDisabled: overrides.channelDisabled ?? false, failed: overrides.failed ?? false, + requestCount: overrides.requestCount ?? 1, + successCalls: overrides.successCalls ?? (overrides.failed ? 0 : 1), + failureCalls: overrides.failureCalls ?? (overrides.failed ? 1 : 0), statsIncluded: overrides.statsIncluded ?? true, latencyMs: overrides.latencyMs ?? 1200, + latencySumMs: overrides.latencySumMs ?? overrides.latencyMs ?? 1200, + latencyCount: overrides.latencyCount ?? 1, inputTokens: overrides.inputTokens ?? 10, outputTokens: overrides.outputTokens ?? 5, reasoningTokens: overrides.reasoningTokens ?? 0, @@ -152,6 +158,22 @@ describe('buildRangeFilteredRows', () => { expect(rows).toHaveLength(1); expect(rows[0].apiKeyHash).toBe('hash-b'); }); + + it('matches text search when the derived api key hash does not match', () => { + const rows = buildRangeFilteredRows( + [ + createMonitoringEventRow({ apiKeyHash: 'hash-a', searchText: 'kongwenpeng codex' }), + createMonitoringEventRow({ id: 'row-2', apiKeyHash: 'hash-b', searchText: 'other alias' }), + ], + 'all', + null, + 'KongWenpeng', + sha256Hex('KongWenpeng') + ); + + expect(rows).toHaveLength(1); + expect(rows[0].apiKeyHash).toBe('hash-a'); + }); }); describe('buildMonitoringAuthMetaMap', () => { @@ -195,3 +217,24 @@ describe('buildApiKeyDisplayMap', () => { expect(map.get(apiKeyHash)?.label).not.toContain('ghp_1234567890abcdef'); }); }); + +describe('buildMonitoringFilterFacetsFromSummary', () => { + it('reads summary facets without requiring detail rows', () => { + const facets = buildMonitoringFilterFacetsFromSummary({ + apis: {}, + facets: { + providers: ['codex'], + accounts: [{ value: 'alice@example.com', label: 'Alice' }], + models: ['gpt-5'], + channels: ['codex'], + api_keys: [{ value: 'hash-a', label: 'Team A' }], + }, + }); + + expect(facets.providers).toEqual(['codex']); + expect(facets.accounts).toEqual([{ value: 'alice@example.com', label: 'Alice' }]); + expect(facets.models).toEqual(['gpt-5']); + expect(facets.channels).toEqual(['codex']); + expect(facets.apiKeys).toEqual([{ value: 'hash-a', label: 'Team A' }]); + }); +}); diff --git a/src/features/monitoring/hooks/useMonitoringData.ts b/src/features/monitoring/hooks/useMonitoringData.ts index e418dc0e6..ae4099c26 100644 --- a/src/features/monitoring/hooks/useMonitoringData.ts +++ b/src/features/monitoring/hooks/useMonitoringData.ts @@ -119,6 +119,55 @@ const readString = (value: unknown) => { return text; }; +const readFiniteNumber = (value: unknown) => { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : 0; +}; + +const normalizeFacetStrings = (value: unknown) => { + if (!Array.isArray(value)) return []; + return Array.from(new Set(value.map(readString).filter(Boolean))).sort((left, right) => + left.localeCompare(right) + ); +}; + +const normalizeFacetOptions = ( + value: unknown, + displayMap?: Map +): MonitoringFilterOption[] => { + if (!Array.isArray(value)) return []; + const options = new Map(); + value.forEach((item) => { + const record = isRecord(item) ? item : null; + const rawValue = record ? record.value : item; + const optionValue = readString(rawValue); + if (!optionValue || options.has(optionValue)) return; + const display = displayMap?.get(optionValue.toLowerCase()); + const fallbackLabel = record ? readString(record.label) : optionValue; + options.set( + optionValue, + sanitizeApiKeyDisplayText(display?.label || fallbackLabel || optionValue, optionValue) + ); + }); + return Array.from(options.entries()) + .map(([value, label]) => ({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label)); +}; + +export const buildMonitoringFilterFacetsFromSummary = ( + payload: unknown, + apiKeyDisplayMap?: Map +): MonitoringFilterFacets => { + const facets = isRecord(payload) && isRecord(payload.facets) ? payload.facets : {}; + return { + providers: normalizeFacetStrings(facets.providers), + accounts: normalizeFacetOptions(facets.accounts), + models: normalizeFacetStrings(facets.models), + channels: normalizeFacetStrings(facets.channels), + apiKeys: normalizeFacetOptions(facets.api_keys ?? facets.apiKeys, apiKeyDisplayMap), + }; +}; + const extractArrayPayload = (payload: unknown, key: string): unknown[] => { if (Array.isArray(payload)) return payload; if (!isRecord(payload)) return []; @@ -168,7 +217,7 @@ const sanitizeApiKeyDisplayText = (value: string, fallback = '') => { return maskSensitiveText(trimmed) || fallback; }; -type ApiKeyDisplayInfo = { +export type ApiKeyDisplayInfo = { label: string; masked: string; }; @@ -381,8 +430,13 @@ export type MonitoringEventRow = { channelHost: string; channelDisabled: boolean; failed: boolean; + requestCount: number; + successCalls: number; + failureCalls: number; statsIncluded: boolean; latencyMs: number | null; + latencySumMs: number; + latencyCount: number; inputTokens: number; outputTokens: number; reasoningTokens: number; @@ -478,6 +532,19 @@ export type MonitoringApiKeyRow = { models: MonitoringApiKeyModelSpendRow[]; }; +export type MonitoringFilterOption = { + value: string; + label: string; +}; + +export type MonitoringFilterFacets = { + providers: string[]; + accounts: MonitoringFilterOption[]; + models: string[]; + channels: string[]; + apiKeys: MonitoringFilterOption[]; +}; + export type MonitoringRealtimeRow = { id: string; account: string; @@ -517,6 +584,12 @@ export type MonitoringMetadata = { export interface UseMonitoringDataParams { usage: unknown; + usagePages?: { + accounts?: { usage?: unknown } | null; + apiKeys?: { usage?: unknown } | null; + realtime?: { usage?: unknown } | null; + models?: { usage?: unknown } | null; + } | null; config: Config | null | undefined; modelPrices: Record; apiKeyAliases?: ApiKeyAlias[]; @@ -544,6 +617,10 @@ export interface UseMonitoringDataReturn { taskBuckets: MonitoringTaskBucketRow[]; recentFailures: MonitoringFailureRow[]; filteredRows: MonitoringEventRow[]; + accountPageRows: MonitoringAccountRow[] | null; + apiKeyPageRows: MonitoringApiKeyRow[] | null; + realtimePageRows: MonitoringEventRow[] | null; + filterFacets: MonitoringFilterFacets; refreshMeta: (showLoading?: boolean) => Promise; } @@ -670,12 +747,14 @@ export const buildRangeFilteredRows = ( return false; } - if (normalizedSearchApiKeyHash && row.apiKeyHash !== normalizedSearchApiKeyHash) { - return false; - } - - if (normalizedQuery && !row.searchText.includes(normalizedQuery)) { - return false; + if (normalizedQuery || normalizedSearchApiKeyHash) { + const matchesText = normalizedQuery ? row.searchText.includes(normalizedQuery) : false; + const matchesAPIKeyHash = normalizedSearchApiKeyHash + ? row.apiKeyHash === normalizedSearchApiKeyHash + : false; + if (!matchesText && !matchesAPIKeyHash) { + return false; + } } return true; @@ -698,7 +777,7 @@ const buildTimeline = ( rows.forEach((row) => { const bucket = map.get(row.hourLabel); if (!bucket) return; - bucket.requests += 1; + bucket.requests += row.requestCount; bucket.tokens += row.totalTokens; bucket.cost += row.totalCost; }); @@ -715,7 +794,7 @@ const buildTimeline = ( tokens: 0, cost: 0, }; - existing.requests += 1; + existing.requests += row.requestCount; existing.tokens += row.totalTokens; existing.cost += row.totalCost; grouped.set(row.dayKey, existing); @@ -743,7 +822,7 @@ const buildHourlyDistribution = (rows: MonitoringEventRow[]) => { const hour = Number(row.hourLabel.slice(0, 2)); const bucket = Number.isFinite(hour) ? buckets[hour] : null; if (!bucket) return; - bucket.requests += 1; + bucket.requests += row.requestCount; bucket.tokens += row.totalTokens; bucket.cost += row.totalCost; }); @@ -760,8 +839,8 @@ const buildRecentPattern = (rows: MonitoringEventRow[], limit = 10) => .map((row) => !row.failed); export const buildMonitoringSummary = (rows: MonitoringEventRow[]): MonitoringSummary => { - const totalCalls = rows.length; - const failureCalls = rows.filter((row) => row.failed).length; + const totalCalls = rows.reduce((sum, row) => sum + row.requestCount, 0); + const failureCalls = rows.reduce((sum, row) => sum + row.failureCalls, 0); const successCalls = Math.max(totalCalls - failureCalls, 0); const inputTokens = rows.reduce((sum, row) => sum + row.inputTokens, 0); const outputTokens = rows.reduce((sum, row) => sum + row.outputTokens, 0); @@ -773,9 +852,8 @@ export const buildMonitoringSummary = (rows: MonitoringEventRow[]): MonitoringSu let latencySum = 0; let latencyCount = 0; rows.forEach((row) => { - if (row.latencyMs === null) return; - latencySum += row.latencyMs; - latencyCount += 1; + latencySum += row.latencySumMs; + latencyCount += row.latencyCount; }); const taskMap = new Map(); @@ -795,6 +873,7 @@ export const buildMonitoringSummary = (rows: MonitoringEventRow[]): MonitoringSu const recentRows = rows.filter( (row) => row.timestampMs >= windowStart && row.timestampMs <= nowMs ); + const recentCalls = recentRows.reduce((sum, row) => sum + row.requestCount, 0); const recentTokens = recentRows.reduce((sum, row) => sum + row.totalTokens, 0); return { @@ -809,7 +888,7 @@ export const buildMonitoringSummary = (rows: MonitoringEventRow[]): MonitoringSu totalTokens, totalCost, averageLatencyMs: latencyCount > 0 ? latencySum / latencyCount : null, - rpm30m: recentRows.length / 30, + rpm30m: recentCalls / 30, tpm30m: recentTokens / 30, avgDailyRequests: totalCalls / activeDayCount, avgDailyTokens: totalTokens / activeDayCount, @@ -817,11 +896,57 @@ export const buildMonitoringSummary = (rows: MonitoringEventRow[]): MonitoringSu approxTaskFailures, approxTaskSuccessRate: approxTasks > 0 ? Math.max(approxTasks - approxTaskFailures, 0) / approxTasks : 1, - zeroTokenCalls: zeroTokenRows.length, + zeroTokenCalls: zeroTokenRows.reduce((sum, row) => sum + row.requestCount, 0), zeroTokenModels: Array.from(new Set(zeroTokenRows.map((row) => row.model))).sort(), }; }; +const buildAggregateMonitoringSummary = ( + usage: unknown, + modelRows: MonitoringEventRow[] | null, + fallbackRows: MonitoringEventRow[] +): MonitoringSummary => { + const rowsSummary = buildMonitoringSummary(modelRows ?? fallbackRows); + if (!modelRows || !isRecord(usage)) { + return rowsSummary; + } + + const tokens = isRecord(usage.tokens) ? usage.tokens : {}; + const totalCalls = readFiniteNumber(usage.total_requests); + const successCalls = readFiniteNumber(usage.success_count); + const failureCalls = readFiniteNumber(usage.failure_count); + const inputTokens = readFiniteNumber(tokens.input_tokens); + const outputTokens = readFiniteNumber(tokens.output_tokens); + const reasoningTokens = readFiniteNumber(tokens.reasoning_tokens); + const cachedTokens = Math.max( + readFiniteNumber(tokens.cached_tokens), + readFiniteNumber(tokens.cache_tokens) + ); + const totalTokens = readFiniteNumber(usage.total_tokens ?? tokens.total_tokens); + const latencyCount = readFiniteNumber(usage.latency_count); + const latencySum = readFiniteNumber(usage.latency_sum_ms); + const averageLatencyMs = + latencyCount > 0 + ? latencySum / latencyCount + : Number.isFinite(Number(usage.latency_ms)) + ? Number(usage.latency_ms) + : null; + + return { + ...rowsSummary, + totalCalls, + successCalls, + failureCalls, + successRate: totalCalls > 0 ? successCalls / totalCalls : 1, + inputTokens, + outputTokens, + reasoningTokens, + cachedTokens, + totalTokens, + averageLatencyMs, + }; +}; + export const buildAccountRows = (rows: MonitoringEventRow[]): MonitoringAccountRow[] => { const grouped = new Map< string, @@ -890,9 +1015,9 @@ export const buildAccountRows = (rows: MonitoringEventRow[]): MonitoringAccountR existing.authLabels.add(row.authLabel); existing.authIndices.add(row.authIndex); existing.channels.add(row.channel); - existing.totalCalls += 1; - existing.successCalls += row.failed ? 0 : 1; - existing.failureCalls += row.failed ? 1 : 0; + existing.totalCalls += row.requestCount; + existing.successCalls += row.successCalls; + existing.failureCalls += row.failureCalls; existing.inputTokens += row.inputTokens; existing.outputTokens += row.outputTokens; existing.cachedTokens += row.cachedTokens; @@ -901,8 +1026,8 @@ export const buildAccountRows = (rows: MonitoringEventRow[]): MonitoringAccountR existing.lastSeenAt = Math.max(existing.lastSeenAt, row.timestampMs); if (row.latencyMs !== null) { - existing.latencySum += row.latencyMs; - existing.latencyCount += 1; + existing.latencySum += row.latencySumMs; + existing.latencyCount += row.latencyCount; } const modelEntry = existing.modelMap.get(row.model) ?? { @@ -918,9 +1043,9 @@ export const buildAccountRows = (rows: MonitoringEventRow[]): MonitoringAccountR lastSeenAt: 0, }; - modelEntry.totalCalls += 1; - modelEntry.successCalls += row.failed ? 0 : 1; - modelEntry.failureCalls += row.failed ? 1 : 0; + modelEntry.totalCalls += row.requestCount; + modelEntry.successCalls += row.successCalls; + modelEntry.failureCalls += row.failureCalls; modelEntry.inputTokens += row.inputTokens; modelEntry.outputTokens += row.outputTokens; modelEntry.cachedTokens += row.cachedTokens; @@ -1061,9 +1186,9 @@ export const buildApiKeyRows = (rows: MonitoringEventRow[]): MonitoringApiKeyRow existing.sourceLabels.add(row.sourceMasked || row.source); existing.channels.add(row.channel); - existing.totalCalls += 1; - existing.successCalls += row.failed ? 0 : 1; - existing.failureCalls += row.failed ? 1 : 0; + existing.totalCalls += row.requestCount; + existing.successCalls += row.successCalls; + existing.failureCalls += row.failureCalls; existing.inputTokens += row.inputTokens; existing.outputTokens += row.outputTokens; existing.cachedTokens += row.cachedTokens; @@ -1072,8 +1197,8 @@ export const buildApiKeyRows = (rows: MonitoringEventRow[]): MonitoringApiKeyRow existing.lastSeenAt = Math.max(existing.lastSeenAt, row.timestampMs); if (row.latencyMs !== null) { - existing.latencySum += row.latencyMs; - existing.latencyCount += 1; + existing.latencySum += row.latencySumMs; + existing.latencyCount += row.latencyCount; } const modelEntry = existing.modelMap.get(row.model) ?? { @@ -1089,9 +1214,9 @@ export const buildApiKeyRows = (rows: MonitoringEventRow[]): MonitoringApiKeyRow lastSeenAt: 0, }; - modelEntry.totalCalls += 1; - modelEntry.successCalls += row.failed ? 0 : 1; - modelEntry.failureCalls += row.failed ? 1 : 0; + modelEntry.totalCalls += row.requestCount; + modelEntry.successCalls += row.successCalls; + modelEntry.failureCalls += row.failureCalls; modelEntry.inputTokens += row.inputTokens; modelEntry.outputTokens += row.outputTokens; modelEntry.cachedTokens += row.cachedTokens; @@ -1207,8 +1332,8 @@ export const buildRealtimeMonitorRows = (rows: MonitoringEventRow[]): Monitoring }; existing.rows.push(row); - existing.successCalls += row.failed ? 0 : 1; - existing.failureCalls += row.failed ? 1 : 0; + existing.successCalls += row.successCalls; + existing.failureCalls += row.failureCalls; existing.inputTokens += row.inputTokens; existing.outputTokens += row.outputTokens; existing.cachedTokens += row.cachedTokens; @@ -1222,8 +1347,8 @@ export const buildRealtimeMonitorRows = (rows: MonitoringEventRow[]): Monitoring } if (row.latencyMs !== null) { - existing.latencySum += row.latencyMs; - existing.latencyCount += 1; + existing.latencySum += row.latencySumMs; + existing.latencyCount += row.latencyCount; } grouped.set(key, existing); @@ -1310,8 +1435,8 @@ const buildModelShareRows = (rows: MonitoringEventRow[]) => { totalTokens: 0, totalCost: 0, }; - existing.requests += 1; - existing.failures += row.failed ? 1 : 0; + existing.requests += row.requestCount; + existing.failures += row.failureCalls; existing.totalTokens += row.totalTokens; existing.totalCost += row.totalCost; grouped.set(row.model, existing); @@ -1373,13 +1498,13 @@ const buildChannelRows = (rows: MonitoringEventRow[]) => { existing.planTypes.add(row.planType); } existing.models.add(row.model); - existing.requests += 1; - existing.failures += row.failed ? 1 : 0; + existing.requests += row.requestCount; + existing.failures += row.failureCalls; existing.totalTokens += row.totalTokens; existing.totalCost += row.totalCost; if (row.latencyMs !== null) { - existing.latencySum += row.latencyMs; - existing.latencyCount += 1; + existing.latencySum += row.latencySumMs; + existing.latencyCount += row.latencyCount; } grouped.set(key, existing); }); @@ -1434,15 +1559,15 @@ const buildModelRows = (rows: MonitoringEventRow[]) => { channels: new Set(), }; - existing.requests += 1; - existing.failures += row.failed ? 1 : 0; + existing.requests += row.requestCount; + existing.failures += row.failureCalls; existing.totalTokens += row.totalTokens; existing.totalCost += row.totalCost; existing.sources.add(row.source); existing.channels.add(row.channel); if (row.latencyMs !== null) { - existing.latencySum += row.latencyMs; - existing.latencyCount += 1; + existing.latencySum += row.latencySumMs; + existing.latencyCount += row.latencyCount; } grouped.set(row.model, existing); @@ -1491,13 +1616,13 @@ const buildFailureSourceRows = (rows: MonitoringEventRow[]) => { latencyCount: 0, }; - existing.totalRequests += 1; + existing.totalRequests += row.requestCount; existing.lastSeenAt = Math.max(existing.lastSeenAt, row.timestampMs); if (row.failed) { - existing.failures += 1; + existing.failures += row.failureCalls; if (row.latencyMs !== null) { - existing.latencySum += row.latencyMs; - existing.latencyCount += 1; + existing.latencySum += row.latencySumMs; + existing.latencyCount += row.latencyCount; } } @@ -1564,15 +1689,15 @@ const buildTaskBuckets = (rows: MonitoringEventRow[]) => { maxLatencyMs: null, }; - existing.calls += 1; - existing.failedCalls += row.failed ? 1 : 0; + existing.calls += row.requestCount; + existing.failedCalls += row.failureCalls; existing.models.add(row.model); existing.endpoints.add(row.endpointPath || row.endpoint); existing.totalTokens += row.totalTokens; existing.totalCost += row.totalCost; if (row.latencyMs !== null) { - existing.latencySum += row.latencyMs; - existing.latencyCount += 1; + existing.latencySum += row.latencySumMs; + existing.latencyCount += row.latencyCount; existing.maxLatencyMs = Math.max(existing.maxLatencyMs ?? 0, row.latencyMs); } @@ -1692,6 +1817,22 @@ const buildEventRows = ( Number(detail.tokens?.total_tokens) || 0, extractTotalTokens(detail) ); + const requestCount = Math.max(Number(detail.request_count) || 1, 1); + const successCalls = Math.max( + Number(detail.success_count) || (detail.failed === true ? 0 : requestCount), + 0 + ); + const failureCalls = Math.max( + Number(detail.failure_count) || (detail.failed === true ? requestCount : 0), + 0 + ); + const latencyCount = Math.max(Number(detail.latency_count) || 0, 0); + const latencySumMs = + latencyCount > 0 + ? Math.max(Number(detail.latency_sum_ms) || 0, 0) + : typeof detail.latency_ms === 'number' + ? detail.latency_ms + : 0; const totalCost = calculateCost(detail, modelPriceIndex); const statsIncluded = detail.failed === true || inputTokens > 0 || outputTokens > 0; const dayKey = buildLocalDayKey(timestampMs); @@ -1728,8 +1869,14 @@ const buildEventRows = ( channelHost: channelMeta?.host || '-', channelDisabled: channelMeta?.disabled || false, failed: detail.failed === true, + requestCount, + successCalls, + failureCalls, statsIncluded, latencyMs: typeof detail.latency_ms === 'number' ? detail.latency_ms : null, + latencySumMs, + latencyCount: + latencyCount > 0 ? latencyCount : typeof detail.latency_ms === 'number' ? 1 : 0, inputTokens, outputTokens, reasoningTokens, @@ -1804,6 +1951,7 @@ const loadMonitoringMetaPayload = async ( export function useMonitoringData({ usage, + usagePages, config, modelPrices, apiKeyAliases, @@ -1903,37 +2051,140 @@ export function useMonitoringData({ return buildApiKeyDisplayMap(config?.apiKeys || [], apiKeyAliases || []); }, [apiKeyAliases, config?.apiKeys]); + const summaryFilterFacets = useMemo( + () => buildMonitoringFilterFacetsFromSummary(usage, apiKeyDisplayMap), + [apiKeyDisplayMap, usage] + ); + const modelPriceIndex = useMemo(() => buildModelPriceIndex(modelPrices), [modelPrices]); + const buildRowsForUsage = useCallback( + (payload: unknown) => { + const details = collectUsageDetailsWithEndpoint(payload); + return buildEventRows( + details, + authMetaMap, + authFileMap, + sourceInfoMap, + channelByAuthIndex, + modelPriceIndex, + apiKeyDisplayMap + ).sort((left, right) => right.timestampMs - left.timestampMs); + }, + [apiKeyDisplayMap, authFileMap, authMetaMap, channelByAuthIndex, modelPriceIndex, sourceInfoMap] + ); + const allRows = useMemo(() => { - const details = collectUsageDetailsWithEndpoint(usage); - return buildEventRows( - details, - authMetaMap, - authFileMap, - sourceInfoMap, - channelByAuthIndex, - modelPriceIndex, - apiKeyDisplayMap - ).sort((left, right) => right.timestampMs - left.timestampMs); + return buildRowsForUsage(usage); + }, [buildRowsForUsage, usage]); + + const accountPageRows = useMemo(() => { + const pageUsage = usagePages?.accounts?.usage; + if (!pageUsage) return null; + const rows = buildRangeFilteredRows( + buildRowsForUsage(pageUsage), + timeRange, + customTimeRange, + searchQuery, + searchApiKeyHash + ); + return buildAccountRows(rows); }, [ - apiKeyDisplayMap, - authFileMap, - authMetaMap, - channelByAuthIndex, - modelPriceIndex, - sourceInfoMap, - usage, + buildRowsForUsage, + customTimeRange, + searchApiKeyHash, + searchQuery, + timeRange, + usagePages?.accounts?.usage, ]); + const apiKeyPageRows = useMemo(() => { + const pageUsage = usagePages?.apiKeys?.usage; + if (!pageUsage) return null; + const rows = buildRangeFilteredRows( + buildRowsForUsage(pageUsage), + timeRange, + customTimeRange, + searchQuery, + searchApiKeyHash + ); + return buildApiKeyRows(rows); + }, [ + buildRowsForUsage, + customTimeRange, + searchApiKeyHash, + searchQuery, + timeRange, + usagePages?.apiKeys?.usage, + ]); + + const realtimePageRows = useMemo(() => { + const pageUsage = usagePages?.realtime?.usage; + if (!pageUsage) return null; + return buildRangeFilteredRows( + buildRowsForUsage(pageUsage), + timeRange, + customTimeRange, + searchQuery, + searchApiKeyHash + ); + }, [ + buildRowsForUsage, + customTimeRange, + searchApiKeyHash, + searchQuery, + timeRange, + usagePages?.realtime?.usage, + ]); + + const modelAggregateRows = useMemo(() => { + const pageUsage = usagePages?.models?.usage; + if (!pageUsage) return null; + return buildRowsForUsage(pageUsage).filter(shouldIncludeInStats); + }, [buildRowsForUsage, usagePages?.models?.usage]); + const filteredRows = useMemo( () => buildRangeFilteredRows(allRows, timeRange, customTimeRange, searchQuery, searchApiKeyHash), [allRows, customTimeRange, searchApiKeyHash, searchQuery, timeRange] ); + const filterFacets = useMemo(() => { + if ( + summaryFilterFacets.providers.length > 0 || + summaryFilterFacets.accounts.length > 0 || + summaryFilterFacets.models.length > 0 || + summaryFilterFacets.channels.length > 0 || + summaryFilterFacets.apiKeys.length > 0 + ) { + return summaryFilterFacets; + } + + const apiKeyOptions = new Map(); + filteredRows.forEach((row) => { + if (!row.apiKeyHash || apiKeyOptions.has(row.apiKeyHash)) return; + apiKeyOptions.set(row.apiKeyHash, row.apiKeyLabel || row.apiKeyMasked || row.apiKeyHash); + }); + + return { + providers: Array.from(new Set(filteredRows.map((row) => row.provider))).filter(Boolean), + accounts: buildAccountRows(filteredRows).map((row) => ({ + value: row.account, + label: + row.displayAccount && row.displayAccount !== row.account + ? `${row.displayAccount} / ${row.account}` + : row.account, + })), + models: Array.from(new Set(filteredRows.map((row) => row.model))).filter(Boolean), + channels: Array.from(new Set(filteredRows.map((row) => row.channel))).filter(Boolean), + apiKeys: Array.from(apiKeyOptions.entries()).map(([value, label]) => ({ value, label })), + }; + }, [filteredRows, summaryFilterFacets]); const statsRows = useMemo(() => filteredRows.filter(shouldIncludeInStats), [filteredRows]); - const summary = useMemo(() => buildMonitoringSummary(statsRows), [statsRows]); + const summary = useMemo( + () => buildAggregateMonitoringSummary(usage, modelAggregateRows, statsRows), + [modelAggregateRows, statsRows, usage] + ); const timelineData = useMemo( () => buildTimeline(statsRows, timeRange, customTimeRange), [customTimeRange, statsRows, timeRange] @@ -1985,6 +2236,10 @@ export function useMonitoringData({ taskBuckets, recentFailures, filteredRows, + accountPageRows, + apiKeyPageRows, + realtimePageRows, + filterFacets, refreshMeta, }; } diff --git a/src/features/monitoring/hooks/useUsageData.test.ts b/src/features/monitoring/hooks/useUsageData.test.ts new file mode 100644 index 000000000..86dc206c4 --- /dev/null +++ b/src/features/monitoring/hooks/useUsageData.test.ts @@ -0,0 +1,125 @@ +import { act, createElement, useEffect } from 'react'; +import { create, type ReactTestRenderer } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useUsageData } from './useUsageData'; + +const { mocks } = vi.hoisted(() => { + return { + mocks: { + getModelPrices: vi.fn(), + saveModelPrices: vi.fn(), + getApiKeyAliases: vi.fn(), + getUsage: vi.fn(), + loadStoredModelPrices: vi.fn(), + clearModelPrices: vi.fn(), + saveStoredModelPrices: vi.fn(), + }, + }; +}); + +vi.mock('@/stores', () => ({ + useAuthStore: (selector: (state: { apiBase: string; managementKey: string }) => unknown) => + selector({ apiBase: 'http://cpa.local', managementKey: 'management-key' }), + useUsageServiceStore: (selector: (state: { enabled: boolean; serviceBase: string }) => unknown) => + selector({ enabled: true, serviceBase: 'http://usage.local' }), +})); + +vi.mock('@/services/api/usageService', () => ({ + isUsageServiceId: (value: string) => value === 'usage-service', + normalizeUsageServiceBase: (value: string) => value.replace(/\/+$/, ''), + usageServiceApi: { + getModelPrices: mocks.getModelPrices, + saveModelPrices: mocks.saveModelPrices, + getApiKeyAliases: mocks.getApiKeyAliases, + getUsage: mocks.getUsage, + }, +})); + +vi.mock('@/utils/connection', () => ({ + detectApiBaseFromLocation: () => '', +})); + +vi.mock('@/utils/usage', () => ({ + clearModelPrices: mocks.clearModelPrices, + loadModelPrices: mocks.loadStoredModelPrices, + saveModelPrices: mocks.saveStoredModelPrices, +})); + +type UseUsageDataHarness = { + getCurrent: () => ReturnType; + unmount: () => void; +}; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const mountUseUsageData = async (): Promise => { + let hook: ReturnType | null = null; + let renderer: ReactTestRenderer | null = null; + + function HookHarness() { + const current = useUsageData(); + useEffect(() => { + hook = current; + }); + return null; + } + + await act(async () => { + renderer = create(createElement(HookHarness)); + await flushPromises(); + }); + + return { + getCurrent: () => { + if (!hook) { + throw new Error('Failed to mount useUsageData test harness'); + } + return hook; + }, + unmount: () => { + if (!renderer) return; + act(() => { + renderer?.unmount(); + }); + }, + }; +}; + +beforeEach(() => { + mocks.getModelPrices.mockReset(); + mocks.saveModelPrices.mockReset(); + mocks.getApiKeyAliases.mockReset(); + mocks.getUsage.mockReset(); + mocks.loadStoredModelPrices.mockReset(); + mocks.clearModelPrices.mockReset(); + mocks.saveStoredModelPrices.mockReset(); + + mocks.getApiKeyAliases.mockResolvedValue({ items: [] }); + mocks.getUsage.mockResolvedValue({}); + mocks.loadStoredModelPrices.mockReturnValue({}); +}); + +describe('useUsageData', () => { + it('reloads model prices when loadModelPrices is called again', async () => { + mocks.getModelPrices + .mockResolvedValueOnce({ prices: { 'gpt-initial': { prompt: 1, completion: 2, cache: 0 } } }) + .mockResolvedValueOnce({ prices: { 'gpt-refreshed': { prompt: 3, completion: 4, cache: 0 } } }); + + const harness = await mountUseUsageData(); + + expect(harness.getCurrent().modelPrices).toEqual({ + 'gpt-initial': { prompt: 1, completion: 2, cache: 0 }, + }); + + await act(async () => { + await harness.getCurrent().loadModelPrices(); + }); + + expect(harness.getCurrent().modelPrices).toEqual({ + 'gpt-refreshed': { prompt: 3, completion: 4, cache: 0 }, + }); + expect(mocks.getModelPrices).toHaveBeenCalledTimes(2); + + harness.unmount(); + }); +}); diff --git a/src/features/monitoring/hooks/useUsageData.ts b/src/features/monitoring/hooks/useUsageData.ts index ab2b16bf2..07a63d550 100644 --- a/src/features/monitoring/hooks/useUsageData.ts +++ b/src/features/monitoring/hooks/useUsageData.ts @@ -7,12 +7,20 @@ import { type ApiKeyAliasesResponse, type ModelPricesResponse, type ModelPriceSyncResponse, + type UsagePageQuery, + type UsagePageResponse, + type UsageQuery, type UsageExportResponse, type UsageImportResponse, } from '@/services/api/usageService'; import { useAuthStore, useUsageServiceStore } from '@/stores'; import { detectApiBaseFromLocation } from '@/utils/connection'; -import { clearModelPrices, loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage'; +import { + clearModelPrices, + loadModelPrices as loadStoredModelPrices, + saveModelPrices, + type ModelPrice, +} from '@/utils/usage'; export interface UsagePayload { total_requests?: number; @@ -23,8 +31,23 @@ export interface UsagePayload { [key: string]: unknown; } +export type UsagePageQueries = { + accounts?: UsagePageQuery; + apiKeys?: UsagePageQuery; + realtime?: UsagePageQuery; + models?: UsagePageQuery; +}; + +export type UsagePages = { + accounts?: UsagePageResponse; + apiKeys?: UsagePageResponse; + realtime?: UsagePageResponse; + models?: UsagePageResponse; +}; + export interface UseUsageDataReturn { usage: UsagePayload | null; + usagePages: UsagePages | null; loading: boolean; error: string; lastRefreshedAt: Date | null; @@ -32,19 +55,55 @@ export interface UseUsageDataReturn { apiKeyAliases: ApiKeyAlias[]; usageServiceAvailable: boolean; setModelPrices: (prices: Record) => Promise; + loadModelPrices: () => Promise; loadApiKeyAliases: () => Promise; syncModelPrices: (models?: string[]) => Promise; exportUsage: () => Promise; importUsage: (file: File) => Promise; - loadUsage: () => Promise; + loadUsage: (queryOverride?: UsageQuery) => Promise; } -export function useUsageData(): UseUsageDataReturn { +const isUsagePageFallbackError = (error: unknown) => { + if (!(error instanceof Error)) return false; + const status = (error as { status?: number }).status; + const code = (error as { code?: string }).code; + return status === 404 || status === 405 || code === 'method_not_allowed'; +}; + +const mergeUsagePayloads = (payloads: UsagePayload[]): UsagePayload => { + const merged: UsagePayload = { apis: {} }; + payloads.forEach((payload) => { + merged.total_requests = Number(merged.total_requests || 0) + Number(payload.total_requests || 0); + merged.success_count = Number(merged.success_count || 0) + Number(payload.success_count || 0); + merged.failure_count = Number(merged.failure_count || 0) + Number(payload.failure_count || 0); + merged.total_tokens = Number(merged.total_tokens || 0) + Number(payload.total_tokens || 0); + if (payload.apis && typeof payload.apis === 'object') { + Object.entries(payload.apis).forEach(([endpoint, api]) => { + if (!api || typeof api !== 'object' || Array.isArray(api)) return; + const sourceModels = (api as { models?: unknown }).models; + if (!sourceModels || typeof sourceModels !== 'object' || Array.isArray(sourceModels)) return; + const mergedApis = merged.apis as Record }>; + const target = mergedApis[endpoint] ?? { models: {} }; + Object.entries(sourceModels).forEach(([model, aggregate]) => { + target.models[model] = aggregate; + }); + mergedApis[endpoint] = target; + }); + } + }); + return merged; +}; + +export function useUsageData( + usageQuery?: UsageQuery, + usagePageQueries?: UsagePageQueries +): UseUsageDataReturn { const apiBase = useAuthStore((state) => state.apiBase); const managementKey = useAuthStore((state) => state.managementKey); const usageServiceEnabled = useUsageServiceStore((state) => state.enabled); const usageServiceBase = useUsageServiceStore((state) => state.serviceBase); const [usage, setUsage] = useState(null); + const [usagePages, setUsagePages] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [lastRefreshedAt, setLastRefreshedAt] = useState(null); @@ -138,8 +197,8 @@ export function useUsageData(): UseUsageDataReturn { [managementKey, resolveUsageServiceBase] ); - const loadModelPricesFromStorage = useCallback(async () => { - const fallbackPrices = loadModelPrices(); + const loadModelPrices = useCallback(async () => { + const fallbackPrices = loadStoredModelPrices(); try { const response = await getModelPricesFromApi(); const apiPrices = response.prices ?? {}; @@ -160,6 +219,95 @@ export function useUsageData(): UseUsageDataReturn { } }, [getModelPricesFromApi, saveModelPricesToApi]); + const loadUsagePages = useCallback( + async (serviceBase: string, queryOverride?: UsageQuery): Promise => { + if (!usagePageQueries) return null; + const activeUsageQuery = queryOverride ?? usageQuery; + try { + const loadModelPages = async () => { + if (!usagePageQueries.models) return undefined; + const first = await usageServiceApi.getUsagePage( + serviceBase, + managementKey, + 'models', + activeUsageQuery, + usagePageQueries.models + ); + const totalItems = Math.max(0, Math.trunc(Number(first.total_items) || 0)); + const pageSize = Math.max(1, Math.trunc(Number(first.page_size) || 1)); + const pageCount = Math.ceil(totalItems / pageSize); + if (pageCount <= 1) return first; + + const rest = await Promise.all( + Array.from({ length: pageCount - 1 }, (_, index) => + usageServiceApi.getUsagePage(serviceBase, managementKey, 'models', activeUsageQuery, { + ...usagePageQueries.models, + page: index + 2, + pageSize, + }) + ) + ); + return { + ...first, + page: 1, + usage: mergeUsagePayloads([first, ...rest].map((page) => page.usage)), + }; + }; + + const [accounts, apiKeys, realtime, models] = await Promise.all([ + usagePageQueries.accounts + ? usageServiceApi.getUsagePage( + serviceBase, + managementKey, + 'accounts', + activeUsageQuery, + usagePageQueries.accounts + ) + : Promise.resolve(undefined), + usagePageQueries.apiKeys + ? usageServiceApi.getUsagePage( + serviceBase, + managementKey, + 'api-keys', + activeUsageQuery, + usagePageQueries.apiKeys + ) + : Promise.resolve(undefined), + usagePageQueries.realtime + ? usageServiceApi.getUsagePage( + serviceBase, + managementKey, + 'realtime', + activeUsageQuery, + usagePageQueries.realtime + ) + : Promise.resolve(undefined), + loadModelPages(), + ]); + return { accounts, apiKeys, realtime, models }; + } catch (error) { + if (isUsagePageFallbackError(error)) { + return null; + } + throw error; + } + }, + [ + managementKey, + usagePageQueries?.accounts?.page, + usagePageQueries?.accounts?.pageSize, + usagePageQueries?.accounts?.sortDirection, + usagePageQueries?.accounts?.sortKey, + usagePageQueries?.apiKeys?.page, + usagePageQueries?.apiKeys?.pageSize, + usagePageQueries?.realtime?.page, + usagePageQueries?.realtime?.pageSize, + usagePageQueries?.models?.page, + usagePageQueries?.models?.pageSize, + usageQuery, + ] + ); + const loadApiKeyAliases = useCallback(async () => { const requestId = aliasRequestIdRef.current + 1; aliasRequestIdRef.current = requestId; @@ -173,7 +321,7 @@ export function useUsageData(): UseUsageDataReturn { } }, [getApiKeyAliasesFromApi]); - const loadUsage = useCallback(async () => { + const loadUsage = useCallback(async (queryOverride?: UsageQuery) => { const requestId = requestIdRef.current + 1; requestIdRef.current = requestId; setLoading(true); @@ -184,13 +332,19 @@ export function useUsageData(): UseUsageDataReturn { if (!serviceBase) { setUsageServiceAvailable(false); setUsage(null); + setUsagePages(null); setLastRefreshedAt(null); return; } setUsageServiceAvailable(true); - const payload = await usageServiceApi.getUsage(serviceBase, managementKey); + const activeUsageQuery = queryOverride ?? usageQuery; + const [payload, pages] = await Promise.all([ + usageServiceApi.getUsage(serviceBase, managementKey, activeUsageQuery), + loadUsagePages(serviceBase, activeUsageQuery), + ]); if (requestIdRef.current !== requestId) return; setUsage(payload ?? null); + setUsagePages(pages); setLastRefreshedAt(new Date()); } catch (err) { if (requestIdRef.current !== requestId) return; @@ -200,13 +354,27 @@ export function useUsageData(): UseUsageDataReturn { setLoading(false); } } - }, [managementKey, resolveUsageServiceBase]); + }, [ + loadUsagePages, + managementKey, + resolveUsageServiceBase, + usageQuery?.account, + usageQuery?.apiKeyHash, + usageQuery?.channel, + usageQuery?.endMs, + usageQuery?.model, + usageQuery?.provider, + usageQuery?.search, + usageQuery?.searchApiKeyHash, + usageQuery?.startMs, + usageQuery?.status, + ]); useEffect(() => { - void loadModelPricesFromStorage(); + void loadModelPrices(); void loadApiKeyAliases(); void loadUsage(); - }, [loadApiKeyAliases, loadModelPricesFromStorage, loadUsage]); + }, [loadApiKeyAliases, loadModelPrices, loadUsage]); const setModelPrices = useCallback( async (prices: Record) => { @@ -234,6 +402,7 @@ export function useUsageData(): UseUsageDataReturn { return { usage, + usagePages, loading, error, lastRefreshedAt, @@ -241,6 +410,7 @@ export function useUsageData(): UseUsageDataReturn { apiKeyAliases, usageServiceAvailable, setModelPrices, + loadModelPrices, loadApiKeyAliases, syncModelPrices, exportUsage: exportUsageFromApi, diff --git a/src/pages/MonitoringCenterPage.tsx b/src/pages/MonitoringCenterPage.tsx index b019d7aeb..2289f83ba 100644 --- a/src/pages/MonitoringCenterPage.tsx +++ b/src/pages/MonitoringCenterPage.tsx @@ -44,7 +44,6 @@ import { import { buildAccountRows, buildApiKeyRows, - buildMonitoringSummary, buildRealtimeMonitorRows, getRangeBounds, type MonitoringAccountModelSpendRow, @@ -269,6 +268,25 @@ const buildPaginationState = ( }; }; +const buildRemotePaginationState = ( + items: readonly T[], + page: number, + pageSize: number, + count: number +): PaginationState => { + const safePageSize = Math.max(1, pageSize); + const totalPages = Math.max(1, Math.ceil(Math.max(0, count) / safePageSize)); + const currentPage = Math.min(Math.max(1, page), totalPages); + const startItem = count > 0 ? (currentPage - 1) * safePageSize + 1 : 0; + return { + currentPage, + totalPages, + pageItems: [...items], + startItem, + endItem: count > 0 ? Math.min(startItem + items.length - 1, count) : 0, + }; +}; + const parsePageSize = (value: string, fallback: number) => { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; @@ -318,13 +336,6 @@ const buildAccountSecondaryText = (row: MonitoringAccountRow) => { return ''; }; -const buildAccountOptionLabel = (row: MonitoringAccountRow) => { - if (!row.displayAccount || row.displayAccount === row.account) { - return row.account; - } - return `${row.displayAccount} / ${row.account}`; -}; - const buildAccountSummaryMetrics = ( row: MonitoringAccountRow, hasPrices: boolean, @@ -1956,8 +1967,74 @@ export function MonitoringCenterPage() { return ''; }, [customDraftEndMs, customDraftStartMs, t]); + const buildUsageQuery = useCallback( + (nowMs: number) => { + const bounds = getRangeBounds(timeRange, nowMs, customTimeRange); + if (!bounds) return undefined; + return { + startMs: Number.isFinite(bounds.startMs) ? bounds.startMs : undefined, + endMs: Number.isFinite(bounds.endMs) ? bounds.endMs : undefined, + account: selectedAccount !== 'all' ? selectedAccount : undefined, + provider: selectedProvider !== 'all' ? selectedProvider : undefined, + model: selectedModel !== 'all' ? selectedModel : undefined, + channel: selectedChannel !== 'all' ? selectedChannel : undefined, + apiKeyHash: selectedApiKeyHash !== 'all' ? selectedApiKeyHash : undefined, + status: selectedStatus !== 'all' ? selectedStatus : undefined, + search: deferredSearch.trim() || undefined, + searchApiKeyHash: deferredSearch.trim() ? deferredSearchApiKeyHash : undefined, + }; + }, + [ + customTimeRange, + deferredSearch, + deferredSearchApiKeyHash, + selectedAccount, + selectedApiKeyHash, + selectedChannel, + selectedModel, + selectedProvider, + selectedStatus, + timeRange, + ] + ); + const usageQuery = useMemo(() => buildUsageQuery(Date.now()), [buildUsageQuery]); + + const usagePageQueries = useMemo( + () => ({ + accounts: { + page: accountPage, + pageSize: accountPageSize, + sortKey: accountSort.key, + sortDirection: accountSort.direction, + }, + apiKeys: { + page: apiKeyPage, + pageSize: apiKeyPageSize, + }, + realtime: { + page: realtimePage, + pageSize: realtimePageSize, + }, + models: { + page: 1, + pageSize: 500, + }, + }), + [ + accountPage, + accountPageSize, + accountSort.direction, + accountSort.key, + apiKeyPage, + apiKeyPageSize, + realtimePage, + realtimePageSize, + ] + ); + const { usage, + usagePages, loading: usageLoading, error: usageError, lastRefreshedAt, @@ -1965,21 +2042,28 @@ export function MonitoringCenterPage() { apiKeyAliases, usageServiceAvailable, setModelPrices, + loadModelPrices, loadApiKeyAliases, syncModelPrices, exportUsage, importUsage, loadUsage, - } = useUsageData(); + } = useUsageData(usageQuery, usagePageQueries); const { loading: monitoringLoading, error: monitoringError, authFiles, filteredRows, + accountPageRows, + apiKeyPageRows, + realtimePageRows, + filterFacets, refreshMeta, + summary: monitoringSummary, } = useMonitoringData({ usage, + usagePages, config, modelPrices, apiKeyAliases, @@ -1990,8 +2074,13 @@ export function MonitoringCenterPage() { }); const refreshAll = useCallback(async () => { - await Promise.all([loadUsage(), loadApiKeyAliases(), refreshMeta(false)]); - }, [loadApiKeyAliases, loadUsage, refreshMeta]); + await Promise.all([ + loadUsage(buildUsageQuery(Date.now())), + loadModelPrices(), + loadApiKeyAliases(), + refreshMeta(false), + ]); + }, [buildUsageQuery, loadApiKeyAliases, loadModelPrices, loadUsage, refreshMeta]); const setCurrentAccountPage = useCallback( (page: number) => { @@ -2091,57 +2180,53 @@ export function MonitoringCenterPage() { const providerOptions = useMemo( () => [ { value: 'all', label: t('monitoring.filter_all_providers') }, - ...Array.from(new Set(filteredRows.map((row) => row.provider))) + ...Array.from(new Set(filterFacets.providers)) .filter(Boolean) .sort((left, right) => left.localeCompare(right)) .map((value) => ({ value, label: value })), ], - [filteredRows, t] + [filterFacets.providers, t] ); - const accountOptionRows = useMemo(() => buildAccountRows(filteredRows), [filteredRows]); - const accountOptions = useMemo( () => [ { value: 'all', label: t('monitoring.filter_all_accounts') }, ...Array.from( - new Map( - accountOptionRows.map((row) => [row.account, buildAccountOptionLabel(row)]) - ).entries() + new Map(filterFacets.accounts.map((item) => [item.value, item.label])).entries() ) .sort((left, right) => left[1].localeCompare(right[1])) .map(([value, label]) => ({ value, label })), ], - [accountOptionRows, t] + [filterFacets.accounts, t] ); const modelOptions = useMemo( () => [ { value: 'all', label: t('monitoring.filter_all_models') }, - ...Array.from(new Set(filteredRows.map((row) => row.model))) + ...Array.from(new Set(filterFacets.models)) .filter(Boolean) .sort((left, right) => left.localeCompare(right)) .map((value) => ({ value, label: value })), ], - [filteredRows, t] + [filterFacets.models, t] ); const channelOptions = useMemo( () => [ { value: 'all', label: t('monitoring.filter_all_channels') }, - ...Array.from(new Set(filteredRows.map((row) => row.channel))) + ...Array.from(new Set(filterFacets.channels)) .filter(Boolean) .sort((left, right) => left.localeCompare(right)) .map((value) => ({ value, label: value })), ], - [filteredRows, t] + [filterFacets.channels, t] ); const apiKeyOptions = useMemo(() => { const optionMap = new Map(); - filteredRows.forEach((row) => { - if (!row.apiKeyHash || optionMap.has(row.apiKeyHash)) return; - optionMap.set(row.apiKeyHash, row.apiKeyLabel || row.apiKeyMasked || row.apiKeyHash); + filterFacets.apiKeys.forEach((item) => { + if (!item.value || optionMap.has(item.value)) return; + optionMap.set(item.value, item.label || item.value); }); return [ @@ -2150,7 +2235,7 @@ export function MonitoringCenterPage() { .sort((left, right) => left[1].localeCompare(right[1])) .map(([value, label]) => ({ value, label })), ]; - }, [filteredRows, t]); + }, [filterFacets.apiKeys, t]); const statusOptions = useMemo( () => [ @@ -2237,7 +2322,7 @@ export function MonitoringCenterPage() { [accountStatusBounds, i18n.language, t] ); - const scopedSummary = useMemo(() => buildMonitoringSummary(scopedStatsRows), [scopedStatsRows]); + const scopedSummary = monitoringSummary; const accountRows = useMemo(() => buildAccountRows(scopedRows), [scopedRows]); const apiKeyRows = useMemo(() => buildApiKeyRows(scopedRows), [scopedRows]); const accountStatusDataByRowId = useMemo( @@ -2256,22 +2341,80 @@ export function MonitoringCenterPage() { () => sortAccountRows(accountRows, accountSort), [accountRows, accountSort] ); + const displayedAccountRows = useMemo( + () => (accountPageRows ? sortAccountRows(accountPageRows, accountSort) : sortedAccountRows), + [accountPageRows, accountSort, sortedAccountRows] + ); + const accountTotalCount = + accountPageRows && usagePages?.accounts + ? Math.max(0, usagePages.accounts.total_items) + : sortedAccountRows.length; const groupedRealtimeRows = useMemo( () => buildRealtimeMonitorRows(scopedStatsRows), [scopedStatsRows] ); - const realtimeLogRows = useMemo(() => buildRealtimeLogRows(scopedRows), [scopedRows]); + const displayedApiKeyRows = apiKeyPageRows ?? apiKeyRows; + const apiKeyTotalCount = + apiKeyPageRows && usagePages?.apiKeys + ? Math.max(0, usagePages.apiKeys.total_items) + : apiKeyRows.length; + const realtimeLogRows = useMemo( + () => buildRealtimeLogRows(realtimePageRows ?? scopedRows), + [realtimePageRows, scopedRows] + ); + const realtimeTotalCount = + realtimePageRows && usagePages?.realtime + ? Math.max(0, usagePages.realtime.total_items) + : realtimeLogRows.length; const accountPagination = useMemo( - () => buildPaginationState(sortedAccountRows, accountPage, accountPageSize), - [accountPage, accountPageSize, sortedAccountRows] + () => + accountPageRows && usagePages?.accounts + ? buildRemotePaginationState( + displayedAccountRows, + usagePages.accounts.page, + usagePages.accounts.page_size, + usagePages.accounts.total_items + ) + : buildPaginationState(sortedAccountRows, accountPage, accountPageSize), + [ + accountPage, + accountPageRows, + accountPageSize, + displayedAccountRows, + sortedAccountRows, + usagePages?.accounts, + ] ); const apiKeyPagination = useMemo( - () => buildPaginationState(apiKeyRows, apiKeyPage, apiKeyPageSize), - [apiKeyPage, apiKeyPageSize, apiKeyRows] + () => + apiKeyPageRows && usagePages?.apiKeys + ? buildRemotePaginationState( + displayedApiKeyRows, + usagePages.apiKeys.page, + usagePages.apiKeys.page_size, + usagePages.apiKeys.total_items + ) + : buildPaginationState(apiKeyRows, apiKeyPage, apiKeyPageSize), + [ + apiKeyPage, + apiKeyPageRows, + apiKeyPageSize, + apiKeyRows, + displayedApiKeyRows, + usagePages?.apiKeys, + ] ); const realtimePagination = useMemo( - () => buildPaginationState(realtimeLogRows, realtimePage, realtimePageSize), - [realtimeLogRows, realtimePage, realtimePageSize] + () => + realtimePageRows && usagePages?.realtime + ? buildRemotePaginationState( + realtimeLogRows, + usagePages.realtime.page, + usagePages.realtime.page_size, + usagePages.realtime.total_items + ) + : buildPaginationState(realtimeLogRows, realtimePage, realtimePageSize), + [realtimeLogRows, realtimePage, realtimePageRows, realtimePageSize, usagePages?.realtime] ); const accountPageResetState = useMemo( () => ({ @@ -3511,7 +3654,7 @@ export function MonitoringCenterPage() { ); })} - {sortedAccountRows.length === 0 ? ( + {accountTotalCount === 0 ? ( {renderMonitoringEmptyState()} @@ -3519,7 +3662,7 @@ export function MonitoringCenterPage() { - ) : sortedAccountRows.length > 0 ? ( + ) : accountPagination.pageItems.length > 0 ? (
{accountPagination.pageItems.map((row) => { const authState = accountAuthStateByRowId.get(row.id) ?? EMPTY_ACCOUNT_AUTH_STATE; @@ -3550,7 +3693,7 @@ export function MonitoringCenterPage() { renderMonitoringEmptyState() )} - {t('monitoring.api_key_summary_keys_count', { count: apiKeyRows.length })} + {t('monitoring.api_key_summary_keys_count', { count: apiKeyTotalCount })}
} > @@ -3647,7 +3790,7 @@ export function MonitoringCenterPage() { ); })} - {apiKeyRows.length === 0 ? ( + {apiKeyTotalCount === 0 ? ( {renderMonitoringEmptyState()} @@ -3656,7 +3799,7 @@ export function MonitoringCenterPage() { - {`${t('monitoring.log_rows')}: ${realtimeLogRows.length}`} + {`${t('monitoring.log_rows')}: ${realtimeTotalCount}`} {`${t('monitoring.recent_failures')}: ${scopedFailureCount}`}