diff --git a/src/features/monitoring/accountOverviewState.ts b/src/features/monitoring/accountOverviewState.ts index fc2c37fc5..778ba3d1f 100644 --- a/src/features/monitoring/accountOverviewState.ts +++ b/src/features/monitoring/accountOverviewState.ts @@ -81,6 +81,24 @@ export type MonitoringStatusRangeBounds = { endMs: number; }; +const isMaskedUsageAccountLabel = (value: string) => { + const trimmed = value.trim(); + return ( + trimmed.startsWith('m:') || + trimmed.startsWith('k:') || + trimmed.includes('/m:') || + trimmed.includes('/k:') + ); +}; + +const getMonitoringAccountGroupKey = (row: MonitoringEventRow) => { + const accountKey = row.account || row.authLabel || row.source; + if (row.sourceKey && isMaskedUsageAccountLabel(accountKey)) { + return row.sourceKey; + } + return accountKey || row.sourceKey; +}; + const ACCOUNT_SORT_KEYS = [ 'totalCalls', 'successCalls', @@ -465,6 +483,7 @@ export const buildMonitoringAccountStatusDataMap = ( ) => { const resolvedBounds = resolveMonitoringStatusRangeBounds(rows, bounds); const grouped = new Map(); + const aliases = new Map>(); if (!resolvedBounds) { return new Map(); @@ -475,18 +494,29 @@ export const buildMonitoringAccountStatusDataMap = ( return; } - const accountKey = row.account || row.authLabel || row.source; + const accountKey = getMonitoringAccountGroupKey(row); const existing = grouped.get(accountKey) ?? []; existing.push(row); grouped.set(accountKey, existing); + + const accountAliases = aliases.get(accountKey) ?? new Set(); + [row.account, row.authLabel, row.source].forEach((value) => { + if (value) accountAliases.add(value); + }); + aliases.set(accountKey, accountAliases); }); - return new Map( - Array.from(grouped.entries()).map(([accountKey, accountRows]) => [ - accountKey, - buildStatusDataForRows(accountRows, resolvedBounds), - ]) - ); + const result = new Map(); + grouped.forEach((accountRows, accountKey) => { + const statusData = buildStatusDataForRows(accountRows, resolvedBounds); + result.set(accountKey, statusData); + aliases.get(accountKey)?.forEach((alias) => { + if (!result.has(alias)) { + result.set(alias, statusData); + } + }); + }); + return result; }; const normalizeAccountIdentityValue = (value: unknown) => diff --git a/src/features/monitoring/hooks/useMonitoringData.test.ts b/src/features/monitoring/hooks/useMonitoringData.test.ts index 727f1ff24..0e6fd6be4 100644 --- a/src/features/monitoring/hooks/useMonitoringData.test.ts +++ b/src/features/monitoring/hooks/useMonitoringData.test.ts @@ -3,10 +3,14 @@ import { buildAccountRows, buildApiKeyDisplayMap, buildMonitoringAuthMetaMap, + buildProviderInfoByUsageSource, + buildProviderPrefixByApiKeyHash, + buildProviderPrefixByUsageSource, type MonitoringEventRow, } from './useMonitoringData'; import { sha256Hex } from '@/utils/apiKeyHash'; import type { AuthFileItem } from '@/types'; +import type { Config } from '@/types/config'; const createMonitoringEventRow = ( overrides: Partial = {} @@ -32,6 +36,7 @@ const createMonitoringEventRow = ( apiKeyLabel: overrides.apiKeyLabel ?? 'ak********sh', apiKeyMasked: overrides.apiKeyMasked ?? 'ak********sh', provider: overrides.provider ?? 'codex', + providerDetail: overrides.providerDetail, planType: overrides.planType ?? 'pro', channel: overrides.channel ?? 'codex', channelHost: overrides.channelHost ?? 'example.com', @@ -64,6 +69,73 @@ describe('buildAccountRows', () => { expect(rows).toHaveLength(1); expect(rows[0].authIndices).toEqual(['auth-123456', 'auth-999999']); }); + + it('keeps provider prefixes visible for masked API key usage sources', () => { + const rows = buildAccountRows([ + createMonitoringEventRow({ + account: 'test1/m:fe_o...8599', + accountMasked: 'test1/m:fe_o...8599', + authLabel: 'test1/m:fe_o...8599', + channel: 'codex', + source: 'test1/m:fe_o...8599', + sourceMasked: 'test1/m:fe_o...8599', + }), + ]); + + expect(rows).toHaveLength(1); + expect(rows[0].account).toBe('test1/m:fe_o...8599'); + expect(rows[0].displayAccount).toBe('test1/m:fe_o...8599'); + }); + + it('merges prefixed and unprefixed rows for the same provider source', () => { + const rows = buildAccountRows([ + createMonitoringEventRow({ + id: 'row-unprefixed', + sourceKey: 'codex:0', + account: 'm:m:******ca', + accountMasked: 'm:m:******ca', + source: 'm:m:******ca', + sourceMasked: 'm:m:******ca', + }), + createMonitoringEventRow({ + id: 'row-prefixed', + sourceKey: 'codex:0', + account: 'misaki-su/m:m:******ca', + accountMasked: 'misaki-su/m:m:******ca', + source: 'misaki-su/m:m:******ca', + sourceMasked: 'misaki-su/m:m:******ca', + timestampMs: Date.parse('2026-05-09T02:12:43.000Z'), + }), + ]); + + expect(rows).toHaveLength(1); + expect(rows[0].totalCalls).toBe(2); + expect(rows[0].account).toBe('misaki-su/m:m:******ca'); + expect(rows[0].displayAccount).toBe('misaki-su/m:m:******ca'); + }); + + it('stores provider detail text for provider key account subtitles', () => { + const rows = buildAccountRows([ + createMonitoringEventRow({ + account: 'misaki-su/m:sk-9...1dca', + accountMasked: 'misaki-su/m:sk-9...1dca', + providerDetail: { + prefix: 'misaki-su', + provider: 'Codex', + baseUrl: 'https://sub.swyel.codes', + }, + }), + ]); + + expect(rows).toHaveLength(1); + expect(rows[0].providerDetails).toEqual([ + { + prefix: 'misaki-su', + provider: 'Codex', + baseUrl: 'https://sub.swyel.codes', + }, + ]); + }); }); describe('buildMonitoringAuthMetaMap', () => { @@ -95,3 +167,63 @@ describe('buildApiKeyDisplayMap', () => { expect(map.get(apiKeyHash)?.masked).toMatch(/^sk/); }); }); + +describe('buildProviderPrefixByApiKeyHash', () => { + it('maps configured provider API keys to their routing prefixes', () => { + const apiKey = 'fe_openai_1234567899'; + const config: Config = { + codexApiKeys: [ + { + apiKey, + prefix: 'test1', + }, + ], + }; + + const map = buildProviderPrefixByApiKeyHash(config); + + expect(map.get(sha256Hex(apiKey).toLowerCase())).toBe('test1'); + }); +}); + +describe('buildProviderPrefixByUsageSource', () => { + it('maps masked usage sources back to configured provider prefixes', () => { + const apiKey = 'sk-9435efa6ebfface4e5be9846607be52b76d9b045c840d5468661a03be5051dca'; + const config: Config = { + codexApiKeys: [ + { + apiKey, + prefix: 'misaki-su', + }, + ], + }; + + const map = buildProviderPrefixByUsageSource(config); + + expect(map.get('m:sk-9...1dca')).toBe('misaki-su'); + }); +}); + +describe('buildProviderInfoByUsageSource', () => { + it('maps masked usage sources back to provider type and base url details', () => { + const apiKey = 'sk-9435efa6ebfface4e5be9846607be52b76d9b045c840d5468661a03be5051dca'; + const config: Config = { + codexApiKeys: [ + { + apiKey, + prefix: 'misaki-su', + baseUrl: 'https://sub.swyel.codes', + }, + ], + }; + + const map = buildProviderInfoByUsageSource(config); + + expect(map.get('m:sk-9...1dca')).toEqual({ + prefix: 'misaki-su', + provider: 'Codex', + baseUrl: 'https://sub.swyel.codes', + }); + expect(map.get('m:m:******ca')?.baseUrl).toBe('https://sub.swyel.codes'); + }); +}); diff --git a/src/features/monitoring/hooks/useMonitoringData.ts b/src/features/monitoring/hooks/useMonitoringData.ts index f63b7f81f..b4fa2af30 100644 --- a/src/features/monitoring/hooks/useMonitoringData.ts +++ b/src/features/monitoring/hooks/useMonitoringData.ts @@ -14,6 +14,7 @@ import { collectUsageDetailsWithEndpoint, extractTotalTokens, normalizeAuthIndex, + normalizeUsageSourceId, type ModelPrice, type UsageDetailWithEndpoint, } from '@/utils/usage'; @@ -163,6 +164,18 @@ type ApiKeyDisplayInfo = { masked: string; }; +type ProviderPrefixEntry = { + apiKey?: string; + prefix?: string; + baseUrl?: string; +}; + +export type ProviderUsageDisplayInfo = { + prefix: string; + provider: string; + baseUrl: string; +}; + export const buildApiKeyDisplayMap = ( apiKeys: string[] = [], apiKeyAliases: ApiKeyAlias[] = [] @@ -187,6 +200,125 @@ export const buildApiKeyDisplayMap = ( return map; }; +const formatProviderName = (provider: string) => { + const trimmed = provider.trim(); + const normalized = trimmed.toLowerCase(); + if (normalized === 'codex') return 'Codex'; + if (normalized === 'claude') return 'Claude'; + if (normalized === 'gemini') return 'Gemini'; + if (normalized === 'vertex') return 'Vertex'; + if (normalized === 'openai') return 'OpenAI'; + return trimmed || 'AI'; +}; + +const buildProviderUsageInfo = ( + entry: ProviderPrefixEntry | null | undefined, + provider: string, + providerPrefix?: string, + providerBaseUrl?: string +): ProviderUsageDisplayInfo | null => { + const apiKey = readString(entry?.apiKey); + if (!apiKey) return null; + const prefix = readString(entry?.prefix) || readString(providerPrefix); + const baseUrl = readString(entry?.baseUrl) || readString(providerBaseUrl); + const providerName = formatProviderName(provider); + return { + prefix, + provider: providerName, + baseUrl, + }; +}; + +export const buildProviderInfoByApiKeyHash = ( + config?: Config | null +): Map => { + const map = new Map(); + + const addEntry = ( + entry: ProviderPrefixEntry | null | undefined, + provider: string, + providerPrefix?: string, + providerBaseUrl?: string + ) => { + const info = buildProviderUsageInfo(entry, provider, providerPrefix, providerBaseUrl); + if (!info) return; + const apiKey = readString(entry?.apiKey); + const hash = sha256Hex(apiKey).toLowerCase(); + if (!hash || map.has(hash)) return; + map.set(hash, info); + }; + + config?.geminiApiKeys?.forEach((entry) => addEntry(entry, 'Gemini')); + config?.claudeApiKeys?.forEach((entry) => addEntry(entry, 'Claude')); + config?.codexApiKeys?.forEach((entry) => addEntry(entry, 'Codex')); + config?.vertexApiKeys?.forEach((entry) => addEntry(entry, 'Vertex')); + config?.openaiCompatibility?.forEach((provider) => { + provider.apiKeyEntries?.forEach((entry) => + addEntry(entry, provider.name || 'OpenAI', provider.prefix, provider.baseUrl) + ); + }); + + return map; +}; + +export const buildProviderPrefixByApiKeyHash = (config?: Config | null): Map => { + const map = new Map(); + buildProviderInfoByApiKeyHash(config).forEach((info, key) => { + if (info.prefix) map.set(key, info.prefix); + }); + return map; +}; + +const buildMaskedUsageSource = (apiKey: string) => { + const trimmed = apiKey.trim(); + if (!trimmed) return ''; + if (trimmed.length <= 8) return 'm:****'; + return `m:${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`; +}; + +export const buildProviderInfoByUsageSource = ( + config?: Config | null +): Map => { + const map = new Map(); + + const addEntry = ( + entry: ProviderPrefixEntry | null | undefined, + provider: string, + providerPrefix?: string, + providerBaseUrl?: string + ) => { + const info = buildProviderUsageInfo(entry, provider, providerPrefix, providerBaseUrl); + if (!info) return; + const apiKey = readString(entry?.apiKey); + const usageSource = buildMaskedUsageSource(apiKey); + const normalizedUsageSource = normalizeUsageSourceId(usageSource); + [usageSource, normalizedUsageSource].forEach((source) => { + if (!source || map.has(source)) return; + map.set(source, info); + }); + }; + + config?.geminiApiKeys?.forEach((entry) => addEntry(entry, 'Gemini')); + config?.claudeApiKeys?.forEach((entry) => addEntry(entry, 'Claude')); + config?.codexApiKeys?.forEach((entry) => addEntry(entry, 'Codex')); + config?.vertexApiKeys?.forEach((entry) => addEntry(entry, 'Vertex')); + config?.openaiCompatibility?.forEach((provider) => { + provider.apiKeyEntries?.forEach((entry) => + addEntry(entry, provider.name || 'OpenAI', provider.prefix, provider.baseUrl) + ); + }); + + return map; +}; + +export const buildProviderPrefixByUsageSource = (config?: Config | null): Map => { + const map = new Map(); + buildProviderInfoByUsageSource(config).forEach((info, key) => { + if (info.prefix) map.set(key, info.prefix); + }); + return map; +}; + const shouldIncludeInStats = ( row: Pick ) => row.failed || row.inputTokens > 0 || row.outputTokens > 0; @@ -201,6 +333,28 @@ const looksLikeMaskedUsageSource = (value: string) => { return trimmed.startsWith('m:') || trimmed.startsWith('k:'); }; +const extractUsageAliasPrefix = (alias: unknown) => { + const text = readString(alias); + if (!text || !text.includes('/')) return ''; + const prefix = text.split('/')[0]?.trim() ?? ''; + if (!prefix || prefix === '-' || /\s/.test(prefix)) return ''; + return prefix; +}; + +const withUsageAliasPrefix = (label: string, prefix: string) => { + const trimmedLabel = label.trim(); + const trimmedPrefix = prefix.trim(); + if (!trimmedLabel || !trimmedPrefix) return trimmedLabel; + if ( + trimmedLabel === '-' || + trimmedLabel === trimmedPrefix || + trimmedLabel.startsWith(`${trimmedPrefix}/`) + ) { + return trimmedLabel; + } + return `${trimmedPrefix}/${trimmedLabel}`; +}; + const resolveAccountDisplayName = (account: string, channels: Iterable) => { const channelLabels = Array.from(new Set(Array.from(channels).filter(isEffectiveLabel))); if (looksLikeMaskedUsageSource(account) && channelLabels.length === 1) { @@ -209,6 +363,30 @@ const resolveAccountDisplayName = (account: string, channels: Iterable) return account || channelLabels[0] || '-'; }; +const isPrefixedUsageLabel = (value: string) => value.includes('/'); + +const choosePreferredUsageLabel = (current: string, next: string) => { + const currentText = current.trim(); + const nextText = next.trim(); + if (!nextText) return currentText; + if (!currentText) return nextText; + if (isPrefixedUsageLabel(nextText) && !isPrefixedUsageLabel(currentText)) return nextText; + return currentText; +}; + +const isMaskedUsageAccountLabel = (value: string) => { + const trimmed = value.trim(); + return looksLikeMaskedUsageSource(trimmed) || trimmed.includes('/m:') || trimmed.includes('/k:'); +}; + +const getMonitoringAccountGroupKey = (row: MonitoringEventRow) => { + const accountKey = row.account || row.authLabel || row.source; + if (row.sourceKey && isMaskedUsageAccountLabel(accountKey)) { + return row.sourceKey; + } + return accountKey || row.sourceKey; +}; + type MonitoringChannelMeta = { key: string; name: string; @@ -364,6 +542,7 @@ export type MonitoringEventRow = { apiKeyLabel: string; apiKeyMasked: string; provider: string; + providerDetail?: ProviderUsageDisplayInfo; planType: string; channel: string; channelHost: string; @@ -423,6 +602,7 @@ export type MonitoringAccountRow = { account: string; displayAccount: string; accountMasked: string; + providerDetails?: ProviderUsageDisplayInfo[]; authLabels: string[]; authIndices: string[]; channels: string[]; @@ -811,6 +991,7 @@ export const buildAccountRows = (rows: MonitoringEventRow[]): MonitoringAccountR } >; rows: MonitoringEventRow[]; + providerDetails: Map; totalCalls: number; successCalls: number; failureCalls: number; @@ -826,7 +1007,7 @@ export const buildAccountRows = (rows: MonitoringEventRow[]): MonitoringAccountR >(); rows.forEach((row) => { - const accountKey = row.account || row.authLabel || row.source; + const accountKey = getMonitoringAccountGroupKey(row); const existing = grouped.get(accountKey) ?? { id: accountKey, account: row.account, @@ -834,6 +1015,7 @@ export const buildAccountRows = (rows: MonitoringEventRow[]): MonitoringAccountR authLabels: new Set(), authIndices: new Set(), channels: new Set(), + providerDetails: new Map(), modelMap: new Map(), rows: [] as MonitoringEventRow[], totalCalls: 0, @@ -849,7 +1031,15 @@ export const buildAccountRows = (rows: MonitoringEventRow[]): MonitoringAccountR lastSeenAt: 0, }; + existing.account = choosePreferredUsageLabel(existing.account, row.account); + existing.accountMasked = choosePreferredUsageLabel(existing.accountMasked, row.accountMasked); existing.rows.push(row); + if (row.providerDetail) { + const providerDetailKey = [row.providerDetail.provider, row.providerDetail.baseUrl].join('::'); + if (!existing.providerDetails.has(providerDetailKey)) { + existing.providerDetails.set(providerDetailKey, row.providerDetail); + } + } existing.authLabels.add(row.authLabel); existing.authIndices.add(row.authIndex); existing.channels.add(row.channel); @@ -903,6 +1093,9 @@ export const buildAccountRows = (rows: MonitoringEventRow[]): MonitoringAccountR account: item.account, displayAccount: resolveAccountDisplayName(item.account, channels), accountMasked: item.accountMasked, + providerDetails: Array.from(item.providerDetails.values()).sort((left, right) => + [left.provider, left.baseUrl].join(' ').localeCompare([right.provider, right.baseUrl].join(' ')) + ), authLabels: Array.from(item.authLabels).sort(), authIndices: Array.from(item.authIndices).sort(), channels, @@ -1420,7 +1613,9 @@ const buildEventRows = ( sourceInfoMap: ReturnType, channelByAuthIndex: Map, modelPrices: Record, - apiKeyDisplayMap: Map + apiKeyDisplayMap: Map, + providerInfoByApiKeyHash: Map, + providerInfoByUsageSource: Map ) => details .map((detail, index) => { @@ -1451,11 +1646,28 @@ const buildEventRows = ( detail.auth_provider_snapshot ?? detail.authProviderSnapshot ); const snapshotDisplay = snapshotAccount || snapshotLabel; - const sourceLabel = authMeta?.label || snapshotDisplay || sourceMeta.displayName || authIndex; - const sourceMasked = maskEmailLike(sourceLabel); - const account = authMeta?.account || snapshotAccount || sourceLabel; - const accountMasked = maskEmailLike(account); const apiKeyHash = readString(detail.api_key_hash ?? detail.apiKeyHash).toLowerCase(); + const usageSource = readString(detail.source); + const matchedApiKeyInfo = providerInfoByApiKeyHash.get(apiKeyHash); + const matchedUsageSourceInfo = providerInfoByUsageSource.get(usageSource); + const matchedProviderInfo = matchedApiKeyInfo || matchedUsageSourceInfo; + const rawSourceLabel = + authMeta?.label || + snapshotDisplay || + (matchedProviderInfo?.prefix && looksLikeMaskedUsageSource(usageSource) + ? usageSource + : sourceMeta.displayName) || + authIndex; + const aliasPrefix = + extractUsageAliasPrefix(detail.alias) || + matchedApiKeyInfo?.prefix || + matchedUsageSourceInfo?.prefix || + ''; + const sourceLabel = withUsageAliasPrefix(rawSourceLabel, aliasPrefix); + const sourceMasked = withUsageAliasPrefix(maskEmailLike(rawSourceLabel), aliasPrefix); + const rawAccount = authMeta?.account || snapshotAccount || rawSourceLabel; + const account = withUsageAliasPrefix(rawAccount, aliasPrefix); + const accountMasked = withUsageAliasPrefix(maskEmailLike(rawAccount), aliasPrefix); const apiKeyDisplay = apiKeyDisplayMap.get(apiKeyHash); const apiKeyLabel = apiKeyDisplay?.label || formatApiKeyHashLabel(apiKeyHash); const apiKeyMasked = apiKeyDisplay?.masked || apiKeyLabel; @@ -1506,7 +1718,8 @@ const buildEventRows = ( apiKeyHash, apiKeyLabel, apiKeyMasked, - provider: authMeta?.provider || snapshotProvider || sourceMeta.type || '-', + provider: authMeta?.provider || snapshotProvider || matchedProviderInfo?.provider || sourceMeta.type || '-', + providerDetail: matchedProviderInfo, planType: authMeta?.planType || '-', channel: channelLabel, channelHost: channelMeta?.host || '-', @@ -1523,6 +1736,8 @@ const buildEventRows = ( taskKey, searchText: buildSearchText( detail.__modelName, + detail.alias, + aliasPrefix, sourceLabel, authMeta?.account, authMeta?.label, @@ -1532,6 +1747,8 @@ const buildEventRows = ( apiKeyMasked, channelLabel, channelMeta?.host, + matchedProviderInfo?.provider, + matchedProviderInfo?.baseUrl, endpointPath, endpointMethod, authMeta?.provider || snapshotProvider, @@ -1687,6 +1904,16 @@ export function useMonitoringData({ return buildApiKeyDisplayMap(config?.apiKeys || [], apiKeyAliases || []); }, [apiKeyAliases, config?.apiKeys]); + const providerInfoByApiKeyHash = useMemo( + () => buildProviderInfoByApiKeyHash(config), + [config] + ); + + const providerInfoByUsageSource = useMemo( + () => buildProviderInfoByUsageSource(config), + [config] + ); + const allRows = useMemo(() => { const details = collectUsageDetailsWithEndpoint(usage); return buildEventRows( @@ -1696,7 +1923,9 @@ export function useMonitoringData({ sourceInfoMap, channelByAuthIndex, modelPrices, - apiKeyDisplayMap + apiKeyDisplayMap, + providerInfoByApiKeyHash, + providerInfoByUsageSource ).sort((left, right) => right.timestampMs - left.timestampMs); }, [ apiKeyDisplayMap, @@ -1704,6 +1933,8 @@ export function useMonitoringData({ authMetaMap, channelByAuthIndex, modelPrices, + providerInfoByApiKeyHash, + providerInfoByUsageSource, sourceInfoMap, usage, ]); diff --git a/src/features/monitoring/realtimeLogRows.ts b/src/features/monitoring/realtimeLogRows.ts new file mode 100644 index 000000000..c993a204c --- /dev/null +++ b/src/features/monitoring/realtimeLogRows.ts @@ -0,0 +1,93 @@ +import type { MonitoringEventRow } from './hooks/useMonitoringData'; + +export type RealtimeLogRow = MonitoringEventRow & { + requestCount: number; + successRate: number; + streamKey: string; + recentPattern: boolean[]; +}; + +const isPrefixedUsageAccount = (value: string) => value.includes('/m:') || value.includes('/k:'); + +const chooseRealtimeDisplayAccount = (current: string, next: string) => { + const currentText = current.trim(); + const nextText = next.trim(); + if (!nextText) return currentText; + if (!currentText) return nextText; + if (isPrefixedUsageAccount(nextText) && !isPrefixedUsageAccount(currentText)) return nextText; + return currentText; +}; + +export const buildRealtimeLogRows = (rows: MonitoringEventRow[]): RealtimeLogRow[] => { + const sortedAsc = [...rows].sort( + (left, right) => left.timestampMs - right.timestampMs || left.id.localeCompare(right.id) + ); + const metricsByStream = new Map(); + const preferredAccountBySourceKey = new Map< + string, + { + account: string; + accountMasked: string; + authLabel: string; + providerDetail: MonitoringEventRow['providerDetail'] | null; + } + >(); + + rows.forEach((row) => { + if (!row.sourceKey) return; + const existing = preferredAccountBySourceKey.get(row.sourceKey) ?? { + account: '', + accountMasked: '', + authLabel: '', + providerDetail: null, + }; + preferredAccountBySourceKey.set(row.sourceKey, { + account: chooseRealtimeDisplayAccount(existing.account, row.account), + accountMasked: chooseRealtimeDisplayAccount(existing.accountMasked, row.accountMasked), + authLabel: chooseRealtimeDisplayAccount(existing.authLabel, row.authLabel), + providerDetail: existing.providerDetail || row.providerDetail || null, + }); + }); + + const enriched = sortedAsc.map((row) => { + const preferredAccount = row.sourceKey ? preferredAccountBySourceKey.get(row.sourceKey) : null; + const displayRow = preferredAccount + ? { + ...row, + account: preferredAccount.account || row.account, + accountMasked: preferredAccount.accountMasked || row.accountMasked, + authLabel: preferredAccount.authLabel || row.authLabel, + providerDetail: preferredAccount.providerDetail || row.providerDetail, + } + : row; + const streamKey = [ + displayRow.sourceKey || displayRow.account, + displayRow.provider, + displayRow.model, + displayRow.channel, + ].join('::'); + const previous = metricsByStream.get(streamKey) ?? { total: 0, success: 0, pattern: [] }; + const nextPattern = [...previous.pattern, !row.failed].slice(-10); + const next = { + total: previous.total + (row.statsIncluded ? 1 : 0), + success: previous.success + (row.statsIncluded && !row.failed ? 1 : 0), + pattern: nextPattern, + }; + metricsByStream.set(streamKey, next); + + return { + ...displayRow, + streamKey, + requestCount: next.total, + successRate: next.total > 0 ? next.success / next.total : 1, + recentPattern: nextPattern, + } satisfies RealtimeLogRow; + }); + + return enriched.sort( + (left, right) => + right.timestampMs - left.timestampMs || + right.requestCount - left.requestCount || + right.id.localeCompare(left.id) + ); +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index eb03bacde..57a22086d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1410,6 +1410,8 @@ "codex_inspection_sampled_meta_idle": "Waiting to start", "account_quota_empty": "No queryable Codex quota is available for this account.", "account_quota_reset_at": "Reset At", + "provider_detail": "{{provider}} provider", + "provider_detail_with_address": "{{provider}} provider · Address: {{baseUrl}}", "realtime_table_title": "Realtime Monitor Table", "realtime_table_desc": "Show individual call logs in realtime with model, channel, status, latency, usage, and cost so request changes are visible immediately.", "model_pricing_desc": "Maintain model pricing directly on the monitoring page and update account, model, and live-log spend instantly.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c546effbe..d3eacab04 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1406,6 +1406,8 @@ "codex_inspection_sampled_meta_idle": "Ожидание запуска", "account_quota_empty": "Для этого аккаунта нет доступной для запроса квоты Codex.", "account_quota_reset_at": "Сброс", + "provider_detail": "Провайдер {{provider}}", + "provider_detail_with_address": "Провайдер {{provider}} · Адрес: {{baseUrl}}", "realtime_table_title": "Таблица realtime monitor", "realtime_table_desc": "Показывает отдельные записи вызовов в realtime с моделью, каналом, статусом, задержкой, usage и cost, чтобы изменения запросов были видны сразу.", "model_pricing_desc": "Управляйте ценами моделей прямо на странице мониторинга, чтобы расходы по аккаунтам, моделям и live log обновлялись мгновенно.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c6def7700..f4fab99d2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1410,6 +1410,8 @@ "codex_inspection_sampled_meta_idle": "等待开始", "account_quota_empty": "当前账号没有可查询的 Codex 配额。", "account_quota_reset_at": "重置时间", + "provider_detail": "{{provider}} 提供商", + "provider_detail_with_address": "{{provider}} 提供商 地址:{{baseUrl}}", "realtime_table_title": "实时监控表", "realtime_table_desc": "按单次调用日志实时展示模型、渠道、状态、耗时、用量和花费,持续追踪请求波动。", "model_pricing_desc": "在监控页直接维护模型单价,账号、模型和实时日志花费会立即联动更新。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index e32ff8f98..a9fef8f9d 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1435,6 +1435,8 @@ "codex_inspection_sampled_meta_idle": "等待開始", "account_quota_empty": "目前帳號沒有可查詢的 Codex 配額。", "account_quota_reset_at": "重置時間", + "provider_detail": "{{provider}} 提供商", + "provider_detail_with_address": "{{provider}} 提供商 地址:{{baseUrl}}", "realtime_table_title": "即時監控表", "realtime_table_desc": "按單次呼叫日誌即時展示模型、渠道、狀態、耗時、用量與花費,持續追蹤請求波動。", "model_pricing_desc": "直接在監控頁維護模型單價,帳號、模型與即時日誌花費會立即聯動更新。", diff --git a/src/pages/MonitoringCenterPage.test.tsx b/src/pages/MonitoringCenterPage.test.tsx index 399d2d697..313885c97 100644 --- a/src/pages/MonitoringCenterPage.test.tsx +++ b/src/pages/MonitoringCenterPage.test.tsx @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'; import type { TFunction } from 'i18next'; import { AccountExpandedDetails, AccountOverviewCard } from './MonitoringCenterPage'; import { buildEmptyMonitoringStatusData } from '@/features/monitoring/accountOverviewState'; +import { buildRealtimeLogRows } from '@/features/monitoring/realtimeLogRows'; +import type { MonitoringEventRow } from '@/features/monitoring/hooks/useMonitoringData'; const t = ((key: string, options?: Record) => { const copy: Record = { @@ -62,6 +64,102 @@ const t = ((key: string, options?: Record) => { return value; }) as TFunction; +const createMonitoringEventRow = ( + overrides: Partial = {} +): MonitoringEventRow => ({ + id: overrides.id ?? 'row-1', + timestamp: overrides.timestamp ?? '2026-05-09T01:12:43.000Z', + timestampMs: overrides.timestampMs ?? Date.parse('2026-05-09T01:12:43.000Z'), + dayKey: overrides.dayKey ?? '2026-05-09', + hourLabel: overrides.hourLabel ?? '01:00', + model: overrides.model ?? 'gpt-5.5', + endpoint: overrides.endpoint ?? 'POST /v1/responses', + endpointMethod: overrides.endpointMethod ?? 'POST', + endpointPath: overrides.endpointPath ?? '/v1/responses', + sourceKey: overrides.sourceKey ?? 'codex:0', + source: overrides.source ?? 'm:m:******ca', + sourceMasked: overrides.sourceMasked ?? 'm:m:******ca', + account: overrides.account ?? 'm:m:******ca', + accountMasked: overrides.accountMasked ?? 'm:m:******ca', + authIndex: overrides.authIndex ?? '-', + authIndexMasked: overrides.authIndexMasked ?? '-', + authLabel: overrides.authLabel ?? 'm:m:******ca', + apiKeyHash: overrides.apiKeyHash ?? 'api-key-hash', + apiKeyLabel: overrides.apiKeyLabel ?? 'sha256:api-key', + apiKeyMasked: overrides.apiKeyMasked ?? 'sha256:api-key', + provider: overrides.provider ?? '-', + providerDetail: overrides.providerDetail, + planType: overrides.planType ?? '-', + channel: overrides.channel ?? 'codex', + channelHost: overrides.channelHost ?? 'example.com', + channelDisabled: overrides.channelDisabled ?? false, + failed: overrides.failed ?? false, + statsIncluded: overrides.statsIncluded ?? true, + latencyMs: overrides.latencyMs ?? 1200, + inputTokens: overrides.inputTokens ?? 10, + outputTokens: overrides.outputTokens ?? 5, + reasoningTokens: overrides.reasoningTokens ?? 0, + cachedTokens: overrides.cachedTokens ?? 3, + totalTokens: overrides.totalTokens ?? 18, + totalCost: overrides.totalCost ?? 0.12, + taskKey: overrides.taskKey ?? 'task-1', + searchText: overrides.searchText ?? 'm:m', +}); + +describe('buildRealtimeLogRows', () => { + it('uses the prefixed account label for realtime rows from the same provider source', () => { + const rows = buildRealtimeLogRows([ + createMonitoringEventRow({ + id: 'old-log', + account: 'm:m:******ca', + accountMasked: 'm:m:******ca', + authLabel: 'm:m:******ca', + }), + createMonitoringEventRow({ + id: 'new-log', + account: 'misaki-su/m:m:******ca', + accountMasked: 'misaki-su/m:m:******ca', + authLabel: 'misaki-su/m:m:******ca', + timestampMs: Date.parse('2026-05-09T02:12:43.000Z'), + }), + ]); + + expect(rows).toHaveLength(2); + expect(rows.every((row) => row.accountMasked === 'misaki-su/m:m:******ca')).toBe(true); + }); + + it('uses provider detail text for realtime rows from the same provider source', () => { + const rows = buildRealtimeLogRows([ + createMonitoringEventRow({ + id: 'old-log', + account: 'm:m:******ca', + accountMasked: 'm:m:******ca', + authLabel: 'm:m:******ca', + }), + createMonitoringEventRow({ + id: 'new-log', + account: 'misaki-su/m:m:******ca', + accountMasked: 'misaki-su/m:m:******ca', + authLabel: 'misaki-su/m:m:******ca', + providerDetail: { + prefix: 'misaki-su', + provider: 'Codex', + baseUrl: 'https://sub.swyel.codes', + }, + timestampMs: Date.parse('2026-05-09T02:12:43.000Z'), + }), + ]); + + expect( + rows.every( + (row) => + row.providerDetail?.provider === 'Codex' && + row.providerDetail.baseUrl === 'https://sub.swyel.codes' + ) + ).toBe(true); + }); +}); + describe('MonitoringCenterPage account card', () => { it('renders bulk action buttons for mixed account auth state', () => { const html = renderToStaticMarkup( @@ -71,6 +169,7 @@ describe('MonitoringCenterPage account card', () => { account: 'account@example.com', displayAccount: 'account@example.com', accountMasked: 'acc***@example.com', + providerDetails: [], authLabels: ['alpha', 'beta'], authIndices: ['1', '2'], channels: ['default'], diff --git a/src/pages/MonitoringCenterPage.tsx b/src/pages/MonitoringCenterPage.tsx index 0e40458e4..b627c81d4 100644 --- a/src/pages/MonitoringCenterPage.tsx +++ b/src/pages/MonitoringCenterPage.tsx @@ -72,6 +72,7 @@ import { type MonitoringAccountOverviewMode, } from '@/features/monitoring/accountOverviewState'; import { sortAccountOverviewCardMetrics } from '@/features/monitoring/accountOverviewCardMetrics'; +import { buildRealtimeLogRows } from '@/features/monitoring/realtimeLogRows'; import { buildMonitoringAccountQuotaTargetsByAccount, type MonitoringAccountQuotaTarget, @@ -177,13 +178,6 @@ type PriceDraft = { cache: string; }; -type RealtimeLogRow = MonitoringEventRow & { - requestCount: number; - successRate: number; - streamKey: string; - recentPattern: boolean[]; -}; - type AccountQuotaWindow = { id: string; label: string; @@ -321,8 +315,30 @@ const getCodexPlanLabel = (planType: string | null | undefined, t: TFunction): s return planType || normalized; }; -const buildAccountSecondaryText = (row: MonitoringAccountRow) => { +const buildProviderDetailText = ( + detail: MonitoringEventRow['providerDetail'] | null | undefined, + t: TFunction +) => { + if (!detail) return ''; + if (detail.baseUrl) { + return t('monitoring.provider_detail_with_address', { + provider: detail.provider, + baseUrl: detail.baseUrl, + }); + } + return t('monitoring.provider_detail', { provider: detail.provider }); +}; + +const buildAccountSecondaryText = (row: MonitoringAccountRow, t: TFunction) => { const primaryText = row.displayAccount || row.account; + const providerDetails = + row.providerDetails + ?.map((detail) => buildProviderDetailText(detail, t)) + .filter((detail) => detail && detail !== primaryText) ?? []; + if (providerDetails.length > 0) { + return joinShort(providerDetails, 2); + } + if (row.account && row.account !== primaryText) { return row.account; } @@ -347,6 +363,11 @@ const buildAccountOptionLabel = (row: MonitoringAccountRow) => { return `${row.displayAccount} / ${row.account}`; }; +const buildRequestSourceMetaText = ( + row: Pick, + t: TFunction +) => buildProviderDetailText(row.providerDetail, t) || row.provider || row.channel || '-'; + const buildAccountSummaryMetrics = ( row: MonitoringAccountRow, hasPrices: boolean, @@ -454,40 +475,6 @@ const requestAccountQuota = async ( }; }; -const buildRealtimeLogRows = (rows: MonitoringEventRow[]): RealtimeLogRow[] => { - const sortedAsc = [...rows].sort( - (left, right) => left.timestampMs - right.timestampMs || left.id.localeCompare(right.id) - ); - const metricsByStream = new Map(); - - const enriched = sortedAsc.map((row) => { - const streamKey = [row.account, row.provider, row.model, row.channel].join('::'); - const previous = metricsByStream.get(streamKey) ?? { total: 0, success: 0, pattern: [] }; - const nextPattern = [...previous.pattern, !row.failed].slice(-10); - const next = { - total: previous.total + (row.statsIncluded ? 1 : 0), - success: previous.success + (row.statsIncluded && !row.failed ? 1 : 0), - pattern: nextPattern, - }; - metricsByStream.set(streamKey, next); - - return { - ...row, - streamKey, - requestCount: next.total, - successRate: next.total > 0 ? next.success / next.total : 1, - recentPattern: nextPattern, - } satisfies RealtimeLogRow; - }); - - return enriched.sort( - (left, right) => - right.timestampMs - left.timestampMs || - right.requestCount - left.requestCount || - right.id.localeCompare(left.id) - ); -}; - function SummaryCard({ label, value, meta, tone, variant = 'primary' }: SummaryCardProps) { const cardClassName = [ styles.summaryCard, @@ -972,16 +959,18 @@ function AccountSummaryPrimary({ row, expanded, onToggle, + t, statusTone = 'enabled', showSecondary = true, }: { row: MonitoringAccountRow; expanded: boolean; onToggle: () => void; + t: TFunction; statusTone?: string; showSecondary?: boolean; }) { - const secondaryText = buildAccountSecondaryText(row); + const secondaryText = buildAccountSecondaryText(row, t); const accountLabel = row.displayAccount || row.account; return ( @@ -1640,7 +1629,7 @@ export function AccountOverviewCard({ const canToggleEnabled = authState.enabledState !== 'unavailable'; const toggleChecked = authState.enabledState === 'enabled'; const statusTone = getAccountStatusTone(authState); - const secondaryText = buildAccountSecondaryText(row); + const secondaryText = buildAccountSecondaryText(row, t); const latestRequestText = new Date(row.lastSeenAt).toLocaleString(locale); return ( @@ -1660,6 +1649,7 @@ export function AccountOverviewCard({ row={row} expanded={isExpanded} onToggle={onToggle} + t={t} statusTone={statusTone} showSecondary={false} /> @@ -3272,6 +3262,7 @@ export function MonitoringCenterPage() { row={row} expanded={isExpanded} onToggle={() => toggleAccountExpanded(row.id, row.account)} + t={t} statusTone={statusTone} /> @@ -3439,8 +3430,8 @@ export function MonitoringCenterPage() { aria-hidden="true" />
- {row.provider} - {row.account || row.authLabel || row.accountMasked || '-'} + {row.accountMasked || row.account || row.authLabel || '-'} + {buildRequestSourceMetaText(row, t)}
diff --git a/src/utils/usage.ts b/src/utils/usage.ts index a14b5460c..f3016c679 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -23,6 +23,7 @@ export interface UsageTokens { export interface UsageDetail { timestamp: string; source: string; + alias?: string; auth_index: string | number | null; api_key_hash?: string; apiKeyHash?: string; @@ -262,6 +263,7 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] { details.push({ timestamp, source: normalizeSourceWithCache(sourceCache, detailRaw.source), + alias: readDetailString(detailRaw.alias), auth_index: (detailRaw.auth_index ?? detailRaw.authIndex ?? detailRaw.AuthIndex ?? @@ -328,6 +330,7 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail details.push({ timestamp, source: normalizeSourceWithCache(sourceCache, detailRaw.source), + alias: readDetailString(detailRaw.alias), auth_index: (detailRaw.auth_index ?? detailRaw.authIndex ?? detailRaw.AuthIndex ?? diff --git a/usage-service/internal/store/store.go b/usage-service/internal/store/store.go index 7de7e13ba..56dbab06c 100644 --- a/usage-service/internal/store/store.go +++ b/usage-service/internal/store/store.go @@ -769,6 +769,7 @@ func (s *Store) RecentEvents(ctx context.Context, limit int) ([]usage.Event, err return nil, err } event.RequestID = requestID.String + event.Alias = usage.AliasFromRawJSON(rawJSON.String) event.Provider = provider.String event.Endpoint = endpoint.String event.Method = method.String diff --git a/usage-service/internal/usage/event.go b/usage-service/internal/usage/event.go index 498afb98c..0e330d9c6 100644 --- a/usage-service/internal/usage/event.go +++ b/usage-service/internal/usage/event.go @@ -16,6 +16,7 @@ type Event struct { EventHash string `json:"event_hash"` TimestampMS int64 `json:"timestamp_ms"` Timestamp string `json:"timestamp"` + Alias string `json:"alias,omitempty"` Provider string `json:"provider,omitempty"` Model string `json:"model"` Endpoint string `json:"endpoint,omitempty"` @@ -55,6 +56,7 @@ type Tokens struct { type Detail struct { Timestamp string `json:"timestamp"` Source string `json:"source"` + Alias string `json:"alias,omitempty"` AuthIndex string `json:"auth_index,omitempty"` APIKeyHash string `json:"api_key_hash,omitempty"` AccountSnapshot string `json:"account_snapshot,omitempty"` @@ -137,6 +139,7 @@ func NormalizeRaw(raw []byte) (Event, error) { Timestamp: timestamp, Provider: readString(record, "provider", "type", "auth_type", "authType"), Model: readString(record, "model", "model_name", "modelName"), + Alias: readString(record, "alias"), Endpoint: endpoint, Method: method, Path: path, @@ -200,6 +203,7 @@ func BuildPayload(events []Event) Payload { modelEntry.Details = append(modelEntry.Details, Detail{ Timestamp: event.Timestamp, Source: event.Source, + Alias: firstNonEmpty(event.Alias, AliasFromRawJSON(event.RawJSON)), AuthIndex: event.AuthIndex, APIKeyHash: event.APIKeyHash, AccountSnapshot: event.AccountSnapshot, @@ -222,6 +226,26 @@ func BuildPayload(events []Event) Payload { return payload } +func AliasFromRawJSON(rawJSON string) string { + if strings.TrimSpace(rawJSON) == "" { + return "" + } + var record map[string]any + if err := json.Unmarshal([]byte(rawJSON), &record); err != nil { + return "" + } + return readString(record, "alias") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + func readTimestamp(record map[string]any) (int64, string) { raw := first(record, "timestamp", "time", "created_at", "createdAt", "created", "request_time", "requestTime") now := time.Now() diff --git a/usage-service/internal/usage/import.go b/usage-service/internal/usage/import.go index 4844c483d..d3f1b1412 100644 --- a/usage-service/internal/usage/import.go +++ b/usage-service/internal/usage/import.go @@ -181,6 +181,7 @@ func eventFromExportedRecord(record map[string]any) (Event, bool, error) { EventHash: eventHash, TimestampMS: timestampMS, Timestamp: timestamp, + Alias: readString(record, "alias"), Provider: readString(record, "provider"), Model: readString(record, "model"), Endpoint: readString(record, "endpoint"), @@ -332,6 +333,7 @@ func eventFromLegacyDetail( Timestamp: normalizedTimestamp, Provider: readString(detail, "provider", "type", "auth_type", "authType"), Model: model, + Alias: readString(detail, "alias"), Endpoint: endpoint, Method: method, Path: path, diff --git a/usage-service/internal/usage/import_test.go b/usage-service/internal/usage/import_test.go index fdc48ad91..368e50907 100644 --- a/usage-service/internal/usage/import_test.go +++ b/usage-service/internal/usage/import_test.go @@ -151,6 +151,30 @@ func TestParseImportPayloadPreservesExportedEventHash(t *testing.T) { } } +func TestBuildPayloadExposesAliasFromRawJSON(t *testing.T) { + payload := BuildPayload([]Event{ + { + EventHash: "stable-hash", + TimestampMS: 1778739768787, + Timestamp: "2026-05-14T06:22:48.787Z", + Provider: "codex", + Model: "gpt-5.5", + Endpoint: "POST /v1/responses", + Source: "m:fe_o...8599", + RawJSON: `{"alias":"test1/gpt-5.5","source":"fe_oa_1757129ed36fff1e6d276a7e85f95f3570799d8445cc8599"}`, + CreatedAtMS: 1778739768788, + }, + }) + + details := payload.APIs["POST /v1/responses"].Models["gpt-5.5"].Details + if len(details) != 1 { + t.Fatalf("details = %#v", details) + } + if details[0].Alias != "test1/gpt-5.5" { + t.Fatalf("alias = %q", details[0].Alias) + } +} + func TestParseImportPayloadJSONLCountsBadLines(t *testing.T) { payload := `{"timestamp":"2026-01-02T03:04:05Z","model":"gpt-4o","endpoint":"GET /v1/models","tokens":{"input_tokens":1}} not-json`