diff --git a/src/features/monitoring/hooks/useMonitoringData.ts b/src/features/monitoring/hooks/useMonitoringData.ts index 3e70604bc..cd4588ce4 100644 --- a/src/features/monitoring/hooks/useMonitoringData.ts +++ b/src/features/monitoring/hooks/useMonitoringData.ts @@ -3,6 +3,7 @@ import { authFilesApi } from '@/services/api/authFiles'; import { apiClient } from '@/services/api/client'; import type { AuthFileItem } from '@/types/authFile'; import type { Config } from '@/types/config'; +import type { UsageServiceApiKeyMapItem } from '@/services/api/usageService'; import type { CredentialInfo } from '@/types/sourceInfo'; import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver'; import { @@ -317,6 +318,9 @@ export type MonitoringEventRow = { authIndex: string; authIndexMasked: string; authLabel: string; + apiKeyHash: string; + apiKeyLabel: string; + apiKeyMasked: string; provider: string; planType: string; channel: string; @@ -395,6 +399,28 @@ export type MonitoringAccountRow = { models: MonitoringAccountModelSpendRow[]; }; +export type MonitoringApiKeyRow = { + id: string; + apiKeyHash: string; + apiKeyLabel: string; + apiKeyMasked: string; + authLabels: string[]; + accounts: string[]; + channels: string[]; + totalCalls: number; + successCalls: number; + failureCalls: number; + successRate: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + totalTokens: number; + totalCost: number; + averageLatencyMs: number | null; + lastSeenAt: number; + recentPattern: boolean[]; +}; + export type MonitoringRealtimeRow = { id: string; account: string; @@ -434,6 +460,7 @@ export type MonitoringMetadata = { export interface UseMonitoringDataParams { usage: unknown; + apiKeyMap: UsageServiceApiKeyMapItem[]; config: Config | null | undefined; modelPrices: Record; timeRange: MonitoringTimeRange; @@ -458,6 +485,7 @@ export interface UseMonitoringDataReturn { failureSourceRows: MonitoringFailureSourceRow[]; taskBuckets: MonitoringTaskBucketRow[]; recentFailures: MonitoringFailureRow[]; + apiKeyRows: MonitoringApiKeyRow[]; filteredRows: MonitoringEventRow[]; refreshMeta: (showLoading?: boolean) => Promise; } @@ -640,6 +668,13 @@ const buildHourlyDistribution = (rows: MonitoringEventRow[]) => { return buckets; }; +const maskClientApiKey = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return '-'; + if (trimmed.length <= 10) return trimmed; + return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`; +}; + const buildRecentPattern = (rows: MonitoringEventRow[], limit = 10) => rows .slice() @@ -709,6 +744,107 @@ export const buildMonitoringSummary = (rows: MonitoringEventRow[]): MonitoringSu }; }; +export const buildApiKeyRows = (rows: MonitoringEventRow[]): MonitoringApiKeyRow[] => { + const grouped = new Map< + string, + { + id: string; + apiKeyHash: string; + apiKeyLabel: string; + apiKeyMasked: string; + authLabels: Set; + accounts: Set; + channels: Set; + rows: MonitoringEventRow[]; + totalCalls: number; + successCalls: number; + failureCalls: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + totalTokens: number; + totalCost: number; + latencySum: number; + latencyCount: number; + lastSeenAt: number; + } + >(); + + rows.forEach((row) => { + const apiKeyHash = row.apiKeyHash || '-'; + const existing = grouped.get(apiKeyHash) ?? { + id: apiKeyHash, + apiKeyHash, + apiKeyLabel: row.apiKeyLabel || '-', + apiKeyMasked: row.apiKeyMasked || '-', + authLabels: new Set(), + accounts: new Set(), + channels: new Set(), + rows: [] as MonitoringEventRow[], + totalCalls: 0, + successCalls: 0, + failureCalls: 0, + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + totalCost: 0, + latencySum: 0, + latencyCount: 0, + lastSeenAt: 0, + }; + + existing.rows.push(row); + existing.authLabels.add(row.authLabel); + existing.accounts.add(row.accountMasked || row.account || '-'); + existing.channels.add(row.channel); + existing.totalCalls += 1; + existing.successCalls += row.failed ? 0 : 1; + existing.failureCalls += row.failed ? 1 : 0; + existing.inputTokens += row.inputTokens; + existing.outputTokens += row.outputTokens; + existing.cachedTokens += row.cachedTokens; + existing.totalTokens += row.totalTokens; + existing.totalCost += row.totalCost; + existing.lastSeenAt = Math.max(existing.lastSeenAt, row.timestampMs); + if (row.latencyMs !== null) { + existing.latencySum += row.latencyMs; + existing.latencyCount += 1; + } + + grouped.set(apiKeyHash, existing); + }); + + return Array.from(grouped.values()) + .map((item) => ({ + id: item.id, + apiKeyHash: item.apiKeyHash, + apiKeyLabel: item.apiKeyLabel, + apiKeyMasked: item.apiKeyMasked, + authLabels: Array.from(item.authLabels).sort(), + accounts: Array.from(item.accounts).sort(), + channels: Array.from(item.channels).sort(), + totalCalls: item.totalCalls, + successCalls: item.successCalls, + failureCalls: item.failureCalls, + successRate: item.totalCalls > 0 ? item.successCalls / item.totalCalls : 1, + inputTokens: item.inputTokens, + outputTokens: item.outputTokens, + cachedTokens: item.cachedTokens, + totalTokens: item.totalTokens, + totalCost: item.totalCost, + averageLatencyMs: item.latencyCount > 0 ? item.latencySum / item.latencyCount : null, + lastSeenAt: item.lastSeenAt, + recentPattern: buildRecentPattern(item.rows), + })) + .sort( + (left, right) => + right.lastSeenAt - left.lastSeenAt || + right.totalCalls - left.totalCalls || + right.totalCost - left.totalCost + ); +}; + export const buildAccountRows = (rows: MonitoringEventRow[]): MonitoringAccountRow[] => { const grouped = new Map< string, @@ -1338,7 +1474,8 @@ const buildEventRows = ( authFileMap: Map, sourceInfoMap: ReturnType, channelByAuthIndex: Map, - modelPrices: Record + modelPrices: Record, + apiKeyHashLabelMap: Map ) => details .map((detail, index) => { @@ -1357,8 +1494,15 @@ const buildEventRows = ( const sourceMasked = maskEmailLike(sourceLabel); const account = authMeta?.account || sourceLabel; const accountMasked = maskEmailLike(account); + const apiKeyHash = readString(detail.api_key_hash); + const resolvedApiKey = apiKeyHashLabelMap.get(apiKeyHash) || ''; + const apiKeyLabel = resolvedApiKey || (apiKeyHash ? `sha256:${apiKeyHash.slice(0, 12)}` : '-'); + const apiKeyMasked = resolvedApiKey ? maskClientApiKey(resolvedApiKey) : apiKeyLabel; const channelMeta = channelByAuthIndex.get(authIndex); - const channelLabel = channelMeta?.name || authMeta?.provider || sourceMeta.type || '-'; + const detailProvider = readString(detail.provider); + const detailAuthType = readString(detail.auth_type); + const resolvedProvider = authMeta?.provider || detailProvider || detailAuthType || sourceMeta.type || '-'; + const channelLabel = channelMeta?.name || resolvedProvider || '-'; const endpoint = readString(detail.__endpoint) || '-'; const endpointMethod = readString(detail.__endpointMethod) || '-'; const endpointPath = readString(detail.__endpointPath) || endpoint; @@ -1395,7 +1539,10 @@ const buildEventRows = ( authIndex, authIndexMasked: maskAuthIndex(authIndex), authLabel: authMeta?.label || sourceMasked, - provider: authMeta?.provider || sourceMeta.type || '-', + apiKeyHash, + apiKeyLabel, + apiKeyMasked, + provider: resolvedProvider, planType: authMeta?.planType || '-', channel: channelLabel, channelHost: channelMeta?.host || '-', @@ -1416,6 +1563,8 @@ const buildEventRows = ( authMeta?.account, authMeta?.label, authIndex, + apiKeyLabel, + apiKeyMasked, channelLabel, channelMeta?.host, endpointPath, @@ -1474,6 +1623,7 @@ const loadMonitoringMetaPayload = async ( export function useMonitoringData({ usage, + apiKeyMap, config, modelPrices, timeRange, @@ -1564,12 +1714,27 @@ export function useMonitoringData({ return map; }, [channels]); + const apiKeyHashLabelMap = useMemo(() => { + const map = new Map(); + apiKeyMap.forEach((item) => { + if (!item?.apiKeyHash) return; + map.set(item.apiKeyHash, item.apiKeyLabel || item.apiKeyMasked || item.apiKeyHash); + }); + return map; + }, [apiKeyMap]); + const allRows = useMemo(() => { const details = collectUsageDetailsWithEndpoint(usage); - return buildEventRows(details, authMetaMap, authFileMap, sourceInfoMap, channelByAuthIndex, modelPrices).sort( - (left, right) => right.timestampMs - left.timestampMs - ); - }, [authFileMap, authMetaMap, channelByAuthIndex, modelPrices, sourceInfoMap, usage]); + return buildEventRows( + details, + authMetaMap, + authFileMap, + sourceInfoMap, + channelByAuthIndex, + modelPrices, + apiKeyHashLabelMap + ).sort((left, right) => right.timestampMs - left.timestampMs); + }, [apiKeyHashLabelMap, authFileMap, authMetaMap, channelByAuthIndex, modelPrices, sourceInfoMap, usage]); const filteredRows = useMemo( () => buildRangeFilteredRows(allRows, timeRange, customTimeRange, searchQuery), @@ -1589,6 +1754,7 @@ export function useMonitoringData({ const failureSourceRows = useMemo(() => buildFailureSourceRows(statsRows), [statsRows]); const taskBuckets = useMemo(() => buildTaskBuckets(statsRows), [statsRows]); const recentFailures = useMemo(() => buildFailureRows(statsRows), [statsRows]); + const apiKeyRows = useMemo(() => buildApiKeyRows(statsRows), [statsRows]); const metadata = useMemo(() => { const planTypes = Array.from( @@ -1628,6 +1794,7 @@ export function useMonitoringData({ failureSourceRows, taskBuckets, recentFailures, + apiKeyRows, filteredRows, refreshMeta, }; diff --git a/src/features/monitoring/hooks/useUsageData.ts b/src/features/monitoring/hooks/useUsageData.ts index 652dd3d3a..1d47a2f26 100644 --- a/src/features/monitoring/hooks/useUsageData.ts +++ b/src/features/monitoring/hooks/useUsageData.ts @@ -8,6 +8,7 @@ import { type ModelPriceSyncResponse, type UsageExportResponse, type UsageImportResponse, + type UsageServiceApiKeyMapItem, } from '@/services/api/usageService'; import { useAuthStore, useUsageServiceStore } from '@/stores'; import { detectApiBaseFromLocation } from '@/utils/connection'; @@ -24,6 +25,7 @@ export interface UsagePayload { export interface UseUsageDataReturn { usage: UsagePayload | null; + apiKeyMap: UsageServiceApiKeyMapItem[]; loading: boolean; error: string; lastRefreshedAt: Date | null; @@ -42,6 +44,7 @@ export function useUsageData(): UseUsageDataReturn { const usageServiceEnabled = useUsageServiceStore((state) => state.enabled); const usageServiceBase = useUsageServiceStore((state) => state.serviceBase); const [usage, setUsage] = useState(null); + const [apiKeyMap, setApiKeyMap] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [lastRefreshedAt, setLastRefreshedAt] = useState(null); @@ -151,8 +154,18 @@ export function useUsageData(): UseUsageDataReturn { usageServiceEnabled && usageServiceBase ? await usageServiceApi.getUsage(usageServiceBase, managementKey) : await apiClient.get('/usage'); + let nextApiKeyMap: UsageServiceApiKeyMapItem[] = []; + if (usageServiceEnabled && usageServiceBase) { + try { + const mapResponse = await usageServiceApi.getApiKeyMap(usageServiceBase, managementKey); + nextApiKeyMap = Array.isArray(mapResponse.items) ? mapResponse.items : []; + } catch { + nextApiKeyMap = []; + } + } if (requestIdRef.current !== requestId) return; setUsage(payload ?? null); + setApiKeyMap(nextApiKeyMap); setLastRefreshedAt(new Date()); } catch (err) { if (requestIdRef.current !== requestId) return; @@ -189,6 +202,7 @@ export function useUsageData(): UseUsageDataReturn { return { usage, + apiKeyMap, loading, error, lastRefreshedAt, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index df71af545..c09129b2b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -55,7 +55,8 @@ "model_alias_placeholder": "Model alias (optional)", "invalid_provider_index": "Invalid provider index.", "unsaved_changes_title": "Unsaved changes", - "unsaved_changes_message": "You have unsaved changes. Leaving now will discard them. Do you want to leave?" + "unsaved_changes_message": "You have unsaved changes. Leaving now will discard them. Do you want to leave?", + "detail": "Detail" }, "title": { "main": "CLI Proxy API Management Center", @@ -1190,6 +1191,8 @@ "clear_account_focus": "Clear Account Focus", "account_overview_title": "Account Overview", "account_overview_desc": "Realtime account-level totals for calls, success and failure, token structure, and spend, with expandable model cost details and live Codex quota fetches.", + "api_key_overview_title": "Client API Key Overview", + "api_key_overview_desc": "Break down calls, success and failure, token structure, and spend by CLIProxyAPI top-level api-keys.", "codex_inspection_entry": "Codex Account Inspection", "codex_inspection_eyebrow": "Codex Account Inspection", "codex_inspection_title": "Codex Account Inspection", @@ -1298,6 +1301,8 @@ "codex_inspection_logs_clear": "Clear logs", "codex_inspection_logs_jump_latest": "Jump to latest", "codex_inspection_action_total": "Suggested", + "codex_inspection_probe_total": "Probe Set", + "codex_inspection_sampled_total": "Sampled This Run", "codex_inspection_sampled_meta_running": "Total {{total}} · {{percent}}%", "codex_inspection_sampled_meta_done": "Total {{total}} · Completed", "codex_inspection_sampled_meta_idle": "Waiting to start", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index afede9001..46a3d2a87 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -55,7 +55,8 @@ "model_alias_placeholder": "模型别名 (可选)", "invalid_provider_index": "无效的提供商索引。", "unsaved_changes_title": "未保存的更改", - "unsaved_changes_message": "你有未保存的更改,离开后将丢失这些更改。确定要离开吗?" + "unsaved_changes_message": "你有未保存的更改,离开后将丢失这些更改。确定要离开吗?", + "detail": "详情" }, "title": { "main": "CLI Proxy API Management Center", @@ -1190,6 +1191,8 @@ "clear_account_focus": "取消账号聚焦", "account_overview_title": "账号汇总", "account_overview_desc": "按账号实时汇总调用次数、成功失败、Token 结构和总花费,可展开查看模型花费明细,并拉取该账号的 Codex 配额。", + "api_key_overview_title": "客户端密钥汇总", + "api_key_overview_desc": "按 CLIProxyAPI 顶层 api-keys 统计调用次数、成功失败、Token 结构与总花费。", "codex_inspection_entry": "Codex 账号巡检", "codex_inspection_eyebrow": "Codex Account Inspection", "codex_inspection_title": "Codex 账号巡检", @@ -1363,7 +1366,9 @@ "pagination_next": "下一页", "pagination_info": "第 {{current}} / {{total}} 页 · 显示 {{start}}-{{end}} / {{count}}", "page_size_label": "每页", - "page_size_option": "{{count}} 条/页" + "page_size_option": "{{count}} 条/页", + "codex_inspection_probe_total": "巡检集合", + "codex_inspection_sampled_total": "本次探测" }, "logs": { "title": "日志查看", diff --git a/src/pages/CodexInspectionPage.tsx b/src/pages/CodexInspectionPage.tsx index f48b4901e..40ff217a2 100644 --- a/src/pages/CodexInspectionPage.tsx +++ b/src/pages/CodexInspectionPage.tsx @@ -11,48 +11,25 @@ import { IconChevronDown, IconChevronUp, IconExternalLink, - IconRefreshCw, IconSettings, - IconTrash2, } from '@/components/ui/icons'; import { - applyCodexInspectionExecutionResult, - buildCodexInspectionError, - buildExecutionFailureMessage, clearCodexInspectionConfigurableSettings, - createCodexInspectionSession, DEFAULT_CODEX_INSPECTION_SETTINGS, - executeCodexInspectionActions, - isCodexInspectionStoppedError, - isSuggestedAction, loadCodexInspectionConfigurableSettings, saveCodexInspectionConfigurableSettings, type CodexInspectionAction, type CodexInspectionConfigurableSettings, - type CodexInspectionLogLevel, - type CodexInspectionProgressSnapshot, - type CodexInspectionResultItem, - type CodexInspectionRunResult, - type CodexInspectionSession, } from '@/features/monitoring/codexInspection'; -import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; +import { authFilesApi, codexInspectionApi, type CodexInspectionLogRow, type CodexInspectionResultRow, type CodexInspectionRunDetailResponse, type CodexInspectionRunRecord } from '@/services/api'; +import type { AuthFileItem } from '@/types/authFile'; +import { useAuthStore, useConfigStore, useNotificationStore, useUsageServiceStore } from '@/stores'; +import { normalizeAuthIndex } from '@/utils/usage'; import styles from './CodexInspectionPage.module.scss'; -type RunStatus = 'idle' | 'running' | 'paused' | 'success' | 'error'; - type ActionFilter = 'all' | 'delete' | 'disable' | 'enable'; - type StatusTone = 'idle' | 'info' | 'good' | 'warn' | 'bad'; -type InspectionLogEntry = { - id: string; - level: CodexInspectionLogLevel; - message: string; - timestamp: number; -}; - -type ExecutionTriggerSource = 'manual' | 'auto'; - type SummaryCard = { key: string; label: string; @@ -83,27 +60,26 @@ type PanelProps = { className?: string; }; +type TargetAccountItem = { + key: string; + fileName: string; + displayAccount: string; + authIndex: string; + provider: string; + disabled: boolean; +}; + const ACTION_FILTERS: ActionFilter[] = ['all', 'delete', 'disable', 'enable']; -const actionToneClass: Record = { +const actionToneClass: Record = { keep: styles.actionKeep, delete: styles.actionDelete, disable: styles.actionDisable, enable: styles.actionEnable, }; -const levelClassMap: Record = { - info: styles.logInfo, - success: styles.logSuccess, - warning: styles.logWarning, - error: styles.logError, -}; - const formatTimestamp = (value: number, locale: string) => new Date(value).toLocaleString(locale); -const formatTime = (value: number, locale: string) => new Date(value).toLocaleTimeString(locale); - -const formatPercent = (value: number | null) => (value === null ? '--' : `${value.toFixed(1)}%`); - +const formatPercent = (value: number | null | undefined) => (value == null ? '--' : `${value.toFixed(1)}%`); const toSettingsDraft = (settings: CodexInspectionConfigurableSettings): InspectionSettingsDraft => ({ targetType: settings.targetType, workers: String(settings.workers), @@ -116,66 +92,62 @@ const toSettingsDraft = (settings: CodexInspectionConfigurableSettings): Inspect autoExecuteActions: settings.autoExecuteActions, }); -const formatActionLabel = (action: CodexInspectionAction, t: TFunction) => { - switch (action) { - case 'delete': - return t('monitoring.codex_inspection_action_delete'); - case 'disable': - return t('monitoring.codex_inspection_action_disable'); - case 'enable': - return t('monitoring.codex_inspection_action_enable'); - case 'keep': - default: - return t('monitoring.codex_inspection_action_keep'); - } +const parseSummaryJson = (run: CodexInspectionRunRecord | null | undefined) => { + if (!run?.summaryJson) return null; + try { return JSON.parse(run.summaryJson) as Record; } catch { return null; } }; -const formatCurrentStateLabel = (item: CodexInspectionResultItem, t: TFunction) => { - if (item.disabled) return t('monitoring.codex_inspection_state_disabled'); - return t('monitoring.codex_inspection_state_enabled'); +const parseProgressJson = (run: CodexInspectionRunRecord | null | undefined) => { + if (!run?.progressJson) return null; + try { return JSON.parse(run.progressJson) as Record; } catch { return null; } }; -const countActions = (items: CodexInspectionResultItem[]) => { - const summary = { - delete: 0, - disable: 0, - enable: 0, - }; - - items.forEach((item) => { - if (item.action === 'delete') summary.delete += 1; - if (item.action === 'disable') summary.disable += 1; - if (item.action === 'enable') summary.enable += 1; - }); - - return summary; -}; +const countActions = (items: CodexInspectionResultRow[]) => ({ + delete: items.filter((item) => item.action === 'delete').length, + disable: items.filter((item) => item.action === 'disable').length, + enable: items.filter((item) => item.action === 'enable').length, +}); -const createIdleProgressSnapshot = (): CodexInspectionProgressSnapshot => ({ - total: 0, - completed: 0, - inFlight: 0, - pending: 0, - percent: 0, - status: 'idle', - summary: { - totalFiles: 0, - probeSetCount: 0, - sampledCount: 0, - deleteCount: 0, - disableCount: 0, - enableCount: 0, - keepCount: 0, - }, - startedAt: Date.now(), - updatedAt: Date.now(), +const buildTargetAccounts = (files: AuthFileItem[], targetType: string): TargetAccountItem[] => + files + .filter((file) => String(file.provider || file.type || '').toLowerCase() === targetType.toLowerCase()) + .map((file) => ({ + key: `${String(file.name || '')}::${normalizeAuthIndex(file['auth_index'] ?? file.authIndex) || '-'}`, + fileName: String(file.name || ''), + displayAccount: + String(file.account || file.email || file.label || file.name || file.id || '-'), + authIndex: normalizeAuthIndex(file['auth_index'] ?? file.authIndex) || '', + provider: String(file.provider || file.type || ''), + disabled: file.disabled === true, + })) + .sort((a, b) => a.fileName.localeCompare(b.fileName)); + +const createSettings = (draft: InspectionSettingsDraft): CodexInspectionConfigurableSettings => ({ + targetType: draft.targetType.trim() || DEFAULT_CODEX_INSPECTION_SETTINGS.targetType, + workers: Math.max(1, Number.parseInt(draft.workers, 10) || DEFAULT_CODEX_INSPECTION_SETTINGS.workers), + deleteWorkers: Math.max(1, Number.parseInt(draft.deleteWorkers, 10) || DEFAULT_CODEX_INSPECTION_SETTINGS.deleteWorkers), + timeout: Math.max(1000, Number.parseInt(draft.timeout, 10) || DEFAULT_CODEX_INSPECTION_SETTINGS.timeout), + retries: Math.max(0, Number.parseInt(draft.retries, 10) || DEFAULT_CODEX_INSPECTION_SETTINGS.retries), + userAgent: draft.userAgent.trim() || DEFAULT_CODEX_INSPECTION_SETTINGS.userAgent, + usedPercentThreshold: Math.max(0, Number.parseFloat(draft.usedPercentThreshold) || DEFAULT_CODEX_INSPECTION_SETTINGS.usedPercentThreshold), + sampleSize: Math.max(0, Number.parseInt(draft.sampleSize, 10) || 0), + autoExecuteActions: draft.autoExecuteActions, }); -const filterByAction = (items: CodexInspectionResultItem[], filter: ActionFilter) => { +const filterByAction = (items: CodexInspectionResultRow[], filter: ActionFilter) => { if (filter === 'all') return items; return items.filter((item) => item.action === filter); }; +const formatActionLabel = (action: string, t: TFunction) => { + switch (action as CodexInspectionAction) { + case 'delete': return t('monitoring.codex_inspection_action_delete'); + case 'disable': return t('monitoring.codex_inspection_action_disable'); + case 'enable': return t('monitoring.codex_inspection_action_enable'); + default: return t('monitoring.codex_inspection_action_keep'); + } +}; + function Panel({ title, subtitle, extra, children, className }: PanelProps) { return ( @@ -194,637 +166,190 @@ function Panel({ title, subtitle, extra, children, className }: PanelProps) { export function CodexInspectionPage() { const { t, i18n } = useTranslation(); const config = useConfigStore((state) => state.config); - const apiBase = useAuthStore((state) => state.apiBase); - const managementKey = useAuthStore((state) => state.managementKey); const connectionStatus = useAuthStore((state) => state.connectionStatus); + const managementKey = useAuthStore((state) => state.managementKey); + const usageServiceEnabled = useUsageServiceStore((state) => state.enabled); + const usageServiceBase = useUsageServiceStore((state) => state.serviceBase); const showNotification = useNotificationStore((state) => state.showNotification); - const showConfirmation = useNotificationStore((state) => state.showConfirmation); - const [inspectionSettings, setInspectionSettings] = useState(() => - loadCodexInspectionConfigurableSettings(config) - ); - const [settingsDraft, setSettingsDraft] = useState(() => - toSettingsDraft(loadCodexInspectionConfigurableSettings(config)) - ); + const [inspectionSettings, setInspectionSettings] = useState(() => loadCodexInspectionConfigurableSettings(config)); + const [settingsDraft, setSettingsDraft] = useState(() => toSettingsDraft(loadCodexInspectionConfigurableSettings(config))); const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); - const [logs, setLogs] = useState([]); const [logsCollapsed, setLogsCollapsed] = useState(true); - const [runStatus, setRunStatus] = useState('idle'); - const [progress, setProgress] = useState(createIdleProgressSnapshot); - const [result, setResult] = useState(null); - const [executing, setExecuting] = useState(false); + const [logs, setLogs] = useState([]); + const [resultRows, setResultRows] = useState([]); + const [selectedRun, setSelectedRun] = useState(null); + const [historyRuns, setHistoryRuns] = useState([]); + const [isLoading, setIsLoading] = useState(false); const [actionFilter, setActionFilter] = useState('all'); - const logCounterRef = useRef(0); - const sessionRef = useRef(null); - const activeSessionIdRef = useRef(null); - const logListRef = useRef(null); - const executeItemsRef = useRef< - (( - items: CodexInspectionResultItem[], - options?: { resultOverride?: CodexInspectionRunResult | null; source?: ExecutionTriggerSource } - ) => Promise) | null - >(null); + const [authFiles, setAuthFiles] = useState([]); + const [selectedTargetKeys, setSelectedTargetKeys] = useState([]); + const [historyOpen, setHistoryOpen] = useState(false); + const [responseModalText, setResponseModalText] = useState(''); + const [responseModalTitle, setResponseModalTitle] = useState(''); + const pollingRef = useRef(null); + + const resolvedServiceBase = useMemo(() => { + if (usageServiceEnabled && usageServiceBase) return usageServiceBase; + try { + const { protocol, hostname } = window.location; + return `${protocol}//${hostname}:18317`; + } catch { + return 'http://127.0.0.1:18317'; + } + }, [usageServiceBase, usageServiceEnabled]); useEffect(() => { const nextSettings = loadCodexInspectionConfigurableSettings(config); setInspectionSettings(nextSettings); - if (!isSettingsModalOpen) { - setSettingsDraft(toSettingsDraft(nextSettings)); - } + if (!isSettingsModalOpen) setSettingsDraft(toSettingsDraft(nextSettings)); }, [config, isSettingsModalOpen]); - const appendLog = useCallback((level: CodexInspectionLogLevel, message: string) => { - logCounterRef.current += 1; - setLogs((previous) => [ - ...previous, - { - id: `${Date.now()}-${logCounterRef.current}`, - level, - message, - timestamp: Date.now(), - }, + const refreshRuns = useCallback(async () => { + const [latest, runs, files] = await Promise.all([ + codexInspectionApi.getLatest(resolvedServiceBase, managementKey), + codexInspectionApi.listRuns(resolvedServiceBase, managementKey), + authFilesApi.list(), ]); - }, []); - - const scrollLogsToBottom = useCallback(() => { - const element = logListRef.current; - if (!element) return; - element.scrollTop = element.scrollHeight; - }, []); + setHistoryRuns(runs.runs || []); + setAuthFiles(files.files || []); + setSelectedRun(latest.run || null); + setResultRows(latest.results || []); + setLogs(latest.logs || []); + }, [managementKey, resolvedServiceBase]); useEffect(() => { - if (logsCollapsed) return; - scrollLogsToBottom(); - }, [logs, logsCollapsed, scrollLogsToBottom]); + if (!resolvedServiceBase || !managementKey) return; + void refreshRuns().catch(() => {}); + }, [managementKey, refreshRuns, resolvedServiceBase]); + + const targetAccounts = useMemo(() => buildTargetAccounts(authFiles, inspectionSettings.targetType), [authFiles, inspectionSettings.targetType]); + const selectedTargets = useMemo(() => new Set(selectedTargetKeys), [selectedTargetKeys]); + const filteredResults = useMemo(() => filterByAction(resultRows.filter((item) => item.action !== 'keep'), actionFilter), [resultRows, actionFilter]); + const pendingActionCount = useMemo(() => resultRows.filter((item) => item.action !== 'keep').length, [resultRows]); + const filterCounts = useMemo(() => ({ all: resultRows.filter((item) => item.action !== 'keep').length, ...countActions(resultRows) }), [resultRows]); + const summary = useMemo(() => parseSummaryJson(selectedRun), [selectedRun]); + const progress = useMemo(() => parseProgressJson(selectedRun), [selectedRun]); useEffect(() => { - return () => { - activeSessionIdRef.current = null; - sessionRef.current?.stop(); - sessionRef.current = null; - }; - }, []); - - const attachSessionPromise = useCallback( - (session: CodexInspectionSession, promise: Promise, autoExecuteOnComplete: boolean) => { - const sessionId = session.id; - - void promise - .then((nextResult) => { - if (activeSessionIdRef.current !== sessionId) return; - const nextActionableResults = nextResult.results.filter(isSuggestedAction); - setResult(nextResult); - setProgress(session.getProgress()); - setRunStatus('success'); - setLogsCollapsed(true); - if (autoExecuteOnComplete) { - if (nextActionableResults.length > 0 && executeItemsRef.current) { - const startedMessage = t('monitoring.codex_inspection_auto_execute_started', { - count: nextActionableResults.length, - }); - appendLog('info', startedMessage); - showNotification(startedMessage, 'info'); - void executeItemsRef.current(nextActionableResults, { - resultOverride: nextResult, - source: 'auto', - }); - return; - } - - const noActionsMessage = t('monitoring.codex_inspection_auto_execute_no_actions'); - appendLog('success', noActionsMessage); - showNotification(noActionsMessage, 'success'); - return; - } - - showNotification(t('monitoring.codex_inspection_run_success'), 'success'); - }) - .catch((error) => { - if (activeSessionIdRef.current !== sessionId) return; - if (isCodexInspectionStoppedError(error)) { - setRunStatus('idle'); - setProgress(createIdleProgressSnapshot()); - return; - } - - const message = buildCodexInspectionError( - error instanceof Error ? error.message : String(error || t('common.unknown_error')) - ); - appendLog('error', message); - setRunStatus('error'); - setLogsCollapsed(false); - showNotification(message, 'error'); - }); - }, - [appendLog, showNotification, t] - ); - - const startFreshInspection = useCallback( - ( - preserveLogs: boolean = false, - introMessage: string = '', - options?: { - autoExecute?: boolean; - } - ) => { - if (connectionStatus !== 'connected') { - const message = t('notification.connection_required'); - showNotification(message, 'warning'); - return; - } - - const autoExecuteOnComplete = options?.autoExecute ?? inspectionSettings.autoExecuteActions; - - if (!preserveLogs) { - setLogs([]); - } - if (introMessage) { - appendLog('info', introMessage); - } - - setResult(null); - setRunStatus('running'); - setLogsCollapsed(false); - setActionFilter('all'); - - const session = createCodexInspectionSession({ - config, - apiBase, - managementKey, - settings: inspectionSettings, - onLog: (level, message) => { - if (activeSessionIdRef.current !== session.id) return; - appendLog(level, message); - }, - onProgress: (snapshot) => { - if (activeSessionIdRef.current !== session.id) return; - setProgress(snapshot); - if (snapshot.status === 'running') { - setRunStatus('running'); - return; - } - if (snapshot.status === 'paused') { - setRunStatus('paused'); - } - }, - onResultsChange: (nextResult) => { - if (activeSessionIdRef.current !== session.id) return; - setResult(nextResult); - }, - }); - - sessionRef.current = session; - activeSessionIdRef.current = session.id; - setProgress(session.getProgress()); - attachSessionPromise(session, session.start(), autoExecuteOnComplete); - }, - [ - apiBase, - appendLog, - attachSessionPromise, - config, - connectionStatus, - inspectionSettings, - managementKey, - showNotification, - t, - ] - ); - - const handleRunInspection = useCallback(() => { - if (runStatus === 'paused' && sessionRef.current) { - setLogsCollapsed(false); - sessionRef.current.resume(); - return; + if (pollingRef.current) { + window.clearInterval(pollingRef.current); + pollingRef.current = null; } - - startFreshInspection(false); - }, [runStatus, startFreshInspection]); - - const handlePauseInspection = useCallback(() => { - if (runStatus !== 'running') return; - sessionRef.current?.pause(); - }, [runStatus]); - - const handleStopInspection = useCallback(() => { - const currentSession = sessionRef.current; - if (!currentSession) return; - - appendLog('warning', t('monitoring.codex_inspection_stopped')); - activeSessionIdRef.current = null; - sessionRef.current = null; - currentSession.stop(); - setRunStatus('idle'); - setProgress(createIdleProgressSnapshot()); - setResult(null); - setLogsCollapsed(false); - }, [appendLog, t]); - - const executeItems = useCallback( - async ( - items: CodexInspectionResultItem[], - options?: { - resultOverride?: CodexInspectionRunResult | null; - source?: ExecutionTriggerSource; - } - ) => { - const currentResult = options?.resultOverride ?? result; - const source = options?.source ?? 'manual'; - if (!currentResult) return; - const targets = items.filter(isSuggestedAction); - if (targets.length === 0) { - showNotification(t('monitoring.codex_inspection_no_pending_actions'), 'info'); - return; - } - - setExecuting(true); - setLogsCollapsed(false); - appendLog('info', t('monitoring.codex_inspection_execute_started')); - - try { - const execution = await executeCodexInspectionActions({ - settings: currentResult.settings, - items: targets, - previousFiles: currentResult.files, - onLog: appendLog, - }); - - const failed = execution.outcomes.filter((item) => !item.success); - if (failed.length > 0) { - showNotification( - `${t('monitoring.codex_inspection_execute_partial')}: ${failed - .slice(0, 2) - .map(buildExecutionFailureMessage) - .join(';')}`, - 'warning' - ); - } else { - showNotification(t('monitoring.codex_inspection_execute_success'), 'success'); - } - const nextResult = applyCodexInspectionExecutionResult(currentResult, execution); - setResult(nextResult); - - if (source === 'auto') { - const successCount = execution.outcomes.filter((item) => item.success).length; - const failedCount = execution.outcomes.length - successCount; - const remainingCount = nextResult.results.filter(isSuggestedAction).length; - const summaryMessage = - failedCount > 0 || remainingCount > 0 - ? t('monitoring.codex_inspection_auto_execute_summary_partial', { - total: targets.length, - success: successCount, - failed: failedCount, - remaining: remainingCount, - }) - : t('monitoring.codex_inspection_auto_execute_summary_success', { - total: targets.length, - success: successCount, - }); - appendLog(failedCount > 0 || remainingCount > 0 ? 'warning' : 'success', summaryMessage); - showNotification(summaryMessage, failedCount > 0 || remainingCount > 0 ? 'warning' : 'success'); - } - } finally { - setExecuting(false); - } - }, - [appendLog, result, showNotification, t] - ); - - useEffect(() => { - executeItemsRef.current = executeItems; - }, [executeItems]); - - const actionableResults = useMemo( - () => (result ? result.results.filter(isSuggestedAction) : []), - [result] - ); - - const filteredResults = useMemo( - () => filterByAction(actionableResults, actionFilter), - [actionableResults, actionFilter] - ); - - const handleExecutePlanned = useCallback(() => { - if (!result) return; - - const targets = actionableResults; - const counts = countActions(targets); - showConfirmation({ - title: t('monitoring.codex_inspection_execute_confirm_title'), - message: t('monitoring.codex_inspection_execute_confirm_body', { - total: targets.length, - delete: counts.delete, - disable: counts.disable, - enable: counts.enable, - }), - confirmText: t('monitoring.codex_inspection_execute_now'), - cancelText: t('common.cancel'), - variant: 'danger', - onConfirm: () => executeItems(targets), - }); - }, [actionableResults, executeItems, result, showConfirmation, t]); - - const handleExecuteSingle = useCallback( - (item: CodexInspectionResultItem) => { - const actionLabel = formatActionLabel(item.action, t); - showConfirmation({ - title: t('monitoring.codex_inspection_execute_single_title'), - message: t('monitoring.codex_inspection_execute_single_body', { - account: item.displayAccount, - action: actionLabel, - }), - confirmText: actionLabel, - cancelText: t('common.cancel'), - variant: item.action === 'delete' ? 'danger' : 'primary', - onConfirm: () => executeItems([item]), - }); - }, - [executeItems, showConfirmation, t] - ); - - const summaryCards = useMemo(() => { - const summarySource = - runStatus === 'running' || runStatus === 'paused' ? progress.summary : result?.summary ?? null; - const blank = '--'; - const dash = '—'; - const probeSetCount = summarySource ? summarySource.probeSetCount : null; - const sampledTotal = summarySource ? summarySource.sampledCount : null; - const sampledCompleted = - summarySource === null - ? null - : runStatus === 'running' || runStatus === 'paused' - ? progress.completed - : summarySource.sampledCount; - const deleteCount = summarySource ? summarySource.deleteCount : null; - const disableCount = summarySource ? summarySource.disableCount : null; - const enableCount = summarySource ? summarySource.enableCount : null; - const totalActions = - summarySource !== null - ? summarySource.deleteCount + summarySource.disableCount + summarySource.enableCount - : null; - - const probeMeta = summarySource - ? `${t('monitoring.codex_inspection_target_type')} ${inspectionSettings.targetType}` - : t('monitoring.codex_inspection_progress_idle'); - - const sampledMeta = (() => { - if (sampledTotal === null) { - return t('monitoring.codex_inspection_sampled_meta_idle'); - } - if (runStatus === 'running' || runStatus === 'paused') { - return t('monitoring.codex_inspection_sampled_meta_running', { - total: sampledTotal, - percent: progress.percent, - }); + if (!selectedRun || (selectedRun.status !== 'running' && selectedRun.status !== 'paused')) return; + pollingRef.current = window.setInterval(() => { + void codexInspectionApi.getRun(resolvedServiceBase, managementKey, selectedRun.runId).then((data: CodexInspectionRunDetailResponse) => { + setSelectedRun(data.run || null); + setResultRows(data.results || []); + setLogs(data.logs || []); + void codexInspectionApi.listRuns(resolvedServiceBase, managementKey).then((runs) => setHistoryRuns(runs.runs || [])).catch(() => {}); + }).catch(() => {}); + }, 2000); + return () => { + if (pollingRef.current) { + window.clearInterval(pollingRef.current); + pollingRef.current = null; } - return t('monitoring.codex_inspection_sampled_meta_done', { total: sampledTotal }); - })(); - - return [ - { - key: 'total-actions', - label: t('monitoring.codex_inspection_action_total'), - value: totalActions === null ? blank : String(totalActions), - meta: - totalActions !== null && totalActions > 0 - ? t('monitoring.codex_inspection_pending_actions') + ` ${totalActions}` - : t('monitoring.codex_inspection_no_pending_actions'), - tone: totalActions && totalActions > 0 ? 'warn' : 'good', - }, - { - key: 'probe-total', - label: t('monitoring.codex_inspection_total_accounts'), - value: probeSetCount === null ? blank : String(probeSetCount), - meta: probeMeta, - }, - { - key: 'sampled', - label: t('monitoring.codex_inspection_sampled_accounts'), - value: sampledCompleted === null ? blank : String(sampledCompleted), - meta: sampledMeta, - }, - { - key: 'delete', - label: t('monitoring.codex_inspection_delete_count'), - value: deleteCount === null ? blank : String(deleteCount), - meta: - deleteCount && deleteCount > 0 - ? t('monitoring.codex_inspection_action_delete') - : dash, - tone: deleteCount && deleteCount > 0 ? 'bad' : undefined, - }, - { - key: 'disable', - label: t('monitoring.codex_inspection_disable_count'), - value: disableCount === null ? blank : String(disableCount), - meta: - disableCount && disableCount > 0 - ? t('monitoring.codex_inspection_action_disable') - : dash, - tone: disableCount && disableCount > 0 ? 'warn' : undefined, - }, - { - key: 'enable', - label: t('monitoring.codex_inspection_enable_count'), - value: enableCount === null ? blank : String(enableCount), - meta: - enableCount && enableCount > 0 - ? t('monitoring.codex_inspection_action_enable') - : dash, - tone: enableCount && enableCount > 0 ? 'good' : undefined, - }, - ]; - }, [ - inspectionSettings.targetType, - progress.completed, - progress.percent, - progress.summary, - result, - runStatus, - t, - ]); - - const pendingActionCount = actionableResults.length; - const progressLabel = - progress.total > 0 - ? t('monitoring.codex_inspection_progress_status', { - completed: progress.completed, - total: progress.total, - inFlight: progress.inFlight, - pending: progress.pending, - percent: progress.percent, - }) - : t('monitoring.codex_inspection_progress_idle'); - const showProgressBar = runStatus === 'running' || runStatus === 'paused'; - - const statusToneMap: Record = { - idle: 'idle', - running: 'info', - paused: 'warn', - success: 'good', - error: 'bad', - }; - - const statusLabelMap: Record = { - idle: t('monitoring.codex_inspection_status_idle'), - running: t('monitoring.codex_inspection_status_running'), - paused: t('monitoring.codex_inspection_status_paused'), - success: t('monitoring.codex_inspection_status_success'), - error: t('monitoring.codex_inspection_status_error'), - }; - - const statusTone = statusToneMap[runStatus]; - const statusLabel = statusLabelMap[runStatus]; - - const lastFinishedLabel = result && result.finishedAt > 0 - ? `${t('monitoring.codex_inspection_last_finished_at')} · ${formatTime(result.finishedAt, i18n.language)}` - : null; - - const openSettingsModal = useCallback(() => { - setSettingsDraft(toSettingsDraft(inspectionSettings)); - setIsSettingsModalOpen(true); - }, [inspectionSettings]); - - const handleSettingsDraftChange = useCallback( - (field: InspectionSettingsDraftField, value: string) => { - setSettingsDraft((previous) => ({ - ...previous, - [field]: value, - })); - }, - [] - ); + }; + }, [managementKey, resolvedServiceBase, selectedRun]); - const handleAutoExecuteChange = useCallback((value: boolean) => { - setSettingsDraft((previous) => ({ - ...previous, - autoExecuteActions: value, - })); + const handleSettingsDraftChange = useCallback((field: InspectionSettingsDraftField, value: string) => { + setSettingsDraft((prev) => ({ ...prev, [field]: value })); }, []); - const parseNonNegativeInteger = useCallback( - (value: string, label: string, min: number) => { - const parsed = Number(value.trim()); - if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < min) { - throw new Error(t('monitoring.codex_inspection_settings_invalid_integer', { field: label, min })); - } - return parsed; - }, - [t] - ); + const handleAutoExecuteChange = useCallback((checked: boolean) => { + setSettingsDraft((prev) => ({ ...prev, autoExecuteActions: checked })); + }, []); const handleSaveSettings = useCallback(() => { - const targetType = settingsDraft.targetType.trim().toLowerCase(); - if (!targetType) { - showNotification(t('monitoring.codex_inspection_settings_target_type_required'), 'error'); + const next = saveCodexInspectionConfigurableSettings(createSettings(settingsDraft)); + setInspectionSettings(next); + setSettingsDraft(toSettingsDraft(next)); + setIsSettingsModalOpen(false); + showNotification(t('common.save_success'), 'success'); + }, [settingsDraft, showNotification, t]); + + const handleResetSettings = useCallback(() => { + clearCodexInspectionConfigurableSettings(); + const next = loadCodexInspectionConfigurableSettings(config); + setInspectionSettings(next); + setSettingsDraft(toSettingsDraft(next)); + }, [config]); + + const handleRunInspection = useCallback(async () => { + if (connectionStatus !== 'connected') { + showNotification(t('notification.connection_required'), 'warning'); return; } - + setIsLoading(true); try { - const nextSettings = saveCodexInspectionConfigurableSettings({ - targetType, - workers: parseNonNegativeInteger( - settingsDraft.workers, - t('monitoring.codex_inspection_settings_workers_label'), - 1 - ), - deleteWorkers: parseNonNegativeInteger( - settingsDraft.deleteWorkers, - t('monitoring.codex_inspection_settings_delete_workers_label'), - 1 - ), - timeout: parseNonNegativeInteger( - settingsDraft.timeout, - t('monitoring.codex_inspection_settings_timeout_label'), - 1 - ), - retries: parseNonNegativeInteger( - settingsDraft.retries, - t('monitoring.codex_inspection_settings_retries_label'), - 0 - ), - userAgent: settingsDraft.userAgent.trim(), - sampleSize: parseNonNegativeInteger( - settingsDraft.sampleSize, - t('monitoring.codex_inspection_settings_sample_size_label'), - 0 - ), - usedPercentThreshold: (() => { - const parsed = Number(settingsDraft.usedPercentThreshold.trim()); - if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) { - throw new Error( - t('monitoring.codex_inspection_settings_invalid_threshold', { - field: t('monitoring.codex_inspection_settings_used_percent_threshold_label'), - }) - ); - } - return parsed; - })(), - autoExecuteActions: settingsDraft.autoExecuteActions, - }); - - setInspectionSettings(nextSettings); - setSettingsDraft(toSettingsDraft(nextSettings)); - setIsSettingsModalOpen(false); - showNotification(t('monitoring.codex_inspection_settings_saved'), 'success'); - } catch (error) { + const payload = { + ...createSettings(settingsDraft), + selectedAccounts: selectedTargetKeys, + }; + await codexInspectionApi.start(resolvedServiceBase, managementKey, payload); + await refreshRuns(); + setLogsCollapsed(false); + showNotification(t('monitoring.codex_inspection_run_success'), 'success'); + } catch (error: unknown) { showNotification(error instanceof Error ? error.message : String(error || t('common.unknown_error')), 'error'); + } finally { + setIsLoading(false); } - }, [parseNonNegativeInteger, settingsDraft, showNotification, t]); - - const handleResetSettings = useCallback(() => { - clearCodexInspectionConfigurableSettings(); - const nextSettings = saveCodexInspectionConfigurableSettings(DEFAULT_CODEX_INSPECTION_SETTINGS); - setInspectionSettings(nextSettings); - setSettingsDraft(toSettingsDraft(nextSettings)); - showNotification(t('monitoring.codex_inspection_settings_reset'), 'success'); - }, [showNotification, t]); + }, [connectionStatus, managementKey, refreshRuns, resolvedServiceBase, selectedTargetKeys, settingsDraft, showNotification, t]); + + const handleStopInspection = useCallback(async () => { + if (!selectedRun?.runId) return; + await codexInspectionApi.stop(resolvedServiceBase, managementKey, selectedRun.runId); + await refreshRuns(); + }, [managementKey, refreshRuns, resolvedServiceBase, selectedRun]); + + const handlePauseInspection = useCallback(async () => { + if (!selectedRun?.runId) return; + await codexInspectionApi.pause(resolvedServiceBase, managementKey, selectedRun.runId); + await refreshRuns(); + }, [managementKey, refreshRuns, resolvedServiceBase, selectedRun]); + + const handleSelectRun = useCallback(async (runId: string) => { + const data = await codexInspectionApi.getRun(resolvedServiceBase, managementKey, runId); + setSelectedRun(data.run || null); + setResultRows(data.results || []); + setLogs(data.logs || []); + setHistoryOpen(false); + }, []); - const handleClearLogs = useCallback(() => { - setLogs([]); + const toggleTargetKey = useCallback((key: string) => { + setSelectedTargetKeys((prev) => prev.includes(key) ? prev.filter((item) => item !== key) : [...prev, key]); }, []); - const handleJumpToLatest = useCallback(() => { - if (logsCollapsed) { - setLogsCollapsed(false); - requestAnimationFrame(scrollLogsToBottom); - return; - } - scrollLogsToBottom(); - }, [logsCollapsed, scrollLogsToBottom]); + const statusTone: StatusTone = selectedRun?.status === 'running' ? 'info' : selectedRun?.status === 'paused' ? 'warn' : selectedRun?.status === 'completed' ? 'good' : selectedRun?.status === 'failed' ? 'bad' : 'idle'; + const statusLabel = selectedRun?.status || 'idle'; + const progressPercent = Number(progress?.percent ?? 0); + const progressLabel = progress ? `\u5df2\u5b8c\u6210 ${progress.completed} / ${progress.total} \u00b7 \u8fdb\u884c\u4e2d ${progress.inFlight} \u00b7 \u5f85\u5904\u7406 ${progress.pending}` : (selectedRun ? `\u5f53\u524d\u5feb\u7167\u672a\u8bb0\u5f55\u8fdb\u5ea6\uff08\u72b6\u6001\uff1a${selectedRun.status}\uff09` : t('monitoring.codex_inspection_progress_idle')); + const isInspectionInFlight = selectedRun?.status === 'running' || selectedRun?.status === 'paused'; + const runButtonLabel = isInspectionInFlight ? t('monitoring.codex_inspection_running') : t('monitoring.codex_inspection_run'); - const filterCounts = useMemo(() => { - const counts = countActions(actionableResults); - return { - all: actionableResults.length, - delete: counts.delete, - disable: counts.disable, - enable: counts.enable, - }; - }, [actionableResults]); + const summaryCards = useMemo(() => { + const blank = '--'; + return [ + { key: 'pending', label: t('monitoring.codex_inspection_action_total'), value: String(pendingActionCount), meta: pendingActionCount > 0 ? t('monitoring.codex_inspection_pending_total') : '\u6682\u65e0', tone: pendingActionCount > 0 ? 'warn' : 'good' }, + { key: 'probe', label: t('monitoring.codex_inspection_probe_total'), value: String(summary?.probeSetCount ?? blank), meta: `${t('monitoring.codex_inspection_target_type')} ${inspectionSettings.targetType}` }, + { key: 'sampled', label: t('monitoring.codex_inspection_sampled_total'), value: String(summary?.sampledCount ?? blank), meta: selectedRun ? `${progressPercent}%` : '\u6682\u65e0' }, + { key: 'delete', label: t('monitoring.codex_inspection_delete_count'), value: String(Number(summary?.deleteCount ?? 0) || blank), meta: '\u6682\u65e0', tone: Number(summary?.deleteCount ?? 0) > 0 ? 'bad' : 'idle' }, + { key: 'disable', label: t('monitoring.codex_inspection_disable_count'), value: String(Number(summary?.disableCount ?? 0) || blank), meta: '\u6682\u65e0', tone: Number(summary?.disableCount ?? 0) > 0 ? 'warn' : 'idle' }, + { key: 'enable', label: t('monitoring.codex_inspection_enable_count'), value: String(Number(summary?.enableCount ?? 0) || blank), meta: '\u6682\u65e0', tone: Number(summary?.enableCount ?? 0) > 0 ? 'good' : 'idle' }, + ]; + }, [inspectionSettings.targetType, pendingActionCount, progressPercent, selectedRun, summary, t]); - const filterLabel = (filter: ActionFilter) => { + const formatFilterLabel = (filter: ActionFilter) => { switch (filter) { - case 'delete': - return t('monitoring.codex_inspection_filter_delete'); - case 'disable': - return t('monitoring.codex_inspection_filter_disable'); - case 'enable': - return t('monitoring.codex_inspection_filter_enable'); - case 'all': - default: - return t('monitoring.codex_inspection_filter_all'); + case 'delete': return t('monitoring.codex_inspection_filter_delete'); + case 'disable': return t('monitoring.codex_inspection_filter_disable'); + case 'enable': return t('monitoring.codex_inspection_filter_enable'); + default: return t('monitoring.codex_inspection_filter_all'); } }; - const isInspectionInFlight = runStatus === 'running' || runStatus === 'paused'; - const runButtonLabel = - runStatus === 'paused' - ? t('monitoring.codex_inspection_resume') - : runStatus === 'running' - ? t('monitoring.codex_inspection_running') - : t('monitoring.codex_inspection_run'); - return (
@@ -844,91 +369,42 @@ export function CodexInspectionPage() { {`${t('monitoring.codex_inspection_threshold')}: ${inspectionSettings.usedPercentThreshold}%`} {`${t('monitoring.codex_inspection_workers')}: ${inspectionSettings.workers}`} {`${t('monitoring.codex_inspection_sample_size')}: ${inspectionSettings.sampleSize || t('common.no')}`} - {inspectionSettings.autoExecuteActions ? ( - - {t('monitoring.codex_inspection_settings_auto_execute_actions_label')} - - ) : null} - {lastFinishedLabel ? {lastFinishedLabel} : null} - {pendingActionCount > 0 ? ( - {`${t('monitoring.codex_inspection_pending_total')} ${pendingActionCount}`} - ) : null} + {selectedRun?.runName ? {selectedRun.runName} : null} + {pendingActionCount > 0 ? {`${t('monitoring.codex_inspection_pending_total')} ${pendingActionCount}`} : null}
-
{t('monitoring.codex_inspection_back')} - + - + {isInspectionInFlight ? ( <> - - + + ) : null}
- - {showProgressBar ? ( + {selectedRun ? (
-
- {t('monitoring.codex_inspection_progress_title')} - {`${progress.percent}%`} -
-
- -
-
- {progressLabel} - {runStatus === 'paused' ? {t('monitoring.codex_inspection_paused')} : null} -
+
{t('monitoring.codex_inspection_progress_title')}{`${progressPercent}%`}
+
+
{progressLabel}
) : null}
{summaryCards.map((card) => ( - + {card.label} {card.value} {card.meta} @@ -936,316 +412,142 @@ export function CodexInspectionPage() { ))}
- - + +
+
+ {ACTION_FILTERS.map((filter) => ( + + ))}
- } - > - {result ? ( - <> -
-
- {ACTION_FILTERS.map((filter) => { - const count = filterCounts[filter]; - const isActive = actionFilter === filter; - return ( - - ); - })} -
-
- -
- - - - - - - - - - - - - - - - - + + {selectedRun ? ( +
+
{t('monitoring.account_label')}{t('monitoring.codex_inspection_current_state')}{t('monitoring.codex_inspection_http_status')}{t('monitoring.codex_inspection_used_percent')}{t('monitoring.codex_inspection_next_action')}{t('common.action')}
+ + + + + + + + + + + + + + + + + + + + {filteredResults.length > 0 ? filteredResults.map((item) => ( + + + + + + + - - - {filteredResults.length > 0 ? ( - filteredResults.map((item) => ( - - - - - - - - - )) - ) : ( - - - - )} - -
{t('monitoring.account_label')}{t('monitoring.codex_inspection_current_state')}{t('monitoring.codex_inspection_http_status')}{t('monitoring.codex_inspection_used_percent')}{t('monitoring.codex_inspection_next_action')}{t('common.action')}
+
+ {item.displayAccount} + {item.fileName}{item.authIndex ? {` ? #${item.authIndex}`} : null} + {item.actionReason ? {item.actionReason} : null} + {item.error ? {item.error} : null} +
+
{item.disabled ? t('monitoring.codex_inspection_state_disabled') : t('monitoring.codex_inspection_state_enabled')}{item.statusCode == null ? '--' : item.statusCode}{formatPercent(item.usedPercent ?? null)}{formatActionLabel(item.action, t)} + +
-
- {item.displayAccount} - - {item.fileName} - {item.authIndex ? ( - {` · #${item.authIndex}`} - ) : null} - - {item.actionReason ? ( - {item.actionReason} - ) : null} - {item.error ? ( - {item.error} - ) : null} -
-
- - {formatCurrentStateLabel(item, t)} - - - {item.statusCode === null ? '--' : item.statusCode} - {formatPercent(item.usedPercent)} - - {formatActionLabel(item.action, t)} - - - -
-
- {actionableResults.length === 0 - ? t('monitoring.codex_inspection_no_pending_actions') - : t('monitoring.codex_inspection_no_pending_actions')} -
-
-
- - ) : ( -
{t('monitoring.codex_inspection_empty')}
- )} + )) : ( +
{t('monitoring.codex_inspection_no_pending_actions')}
+ )} + + +
+ ) :
{t('monitoring.codex_inspection_empty')}
}
- - - - - - } - > + }> {!logsCollapsed ? ( -
- {logs.length > 0 ? ( - logs.map((entry) => ( -
- {formatTimestamp(entry.timestamp, i18n.language)} - {entry.message} -
- )) - ) : ( -
{t('monitoring.codex_inspection_logs_empty')}
- )} -
- ) : ( -
- {t('monitoring.codex_inspection_logs_collapsed', { count: logs.length })} +
+ {logs.length > 0 ? logs.map((entry) => ( +
+ {formatTimestamp(entry.createdAtMs, i18n.language)} + {entry.message} +
+ )) :
{t('monitoring.codex_inspection_logs_empty')}
}
- )} + ) :
{t('monitoring.codex_inspection_logs_collapsed', { count: logs.length })}
} - setIsSettingsModalOpen(false)} - title={t('monitoring.codex_inspection_settings_title')} - width={920} - className={styles.settingsModal} - > + setHistoryOpen(false)} title="巡检历史" width={820} className={styles.settingsModal}> +
+ + + + {historyRuns.map((run) => ( + void handleSelectRun(run.runId)}> + + + + + + + ))} + +
快照状态目标开始时间操作
{run.runName}{run.status}{run.targetType}{formatTimestamp(run.startedAtMs, i18n.language)}
+
+
+ + setIsSettingsModalOpen(false)} title={t('monitoring.codex_inspection_settings_title')} width={920} className={styles.settingsModal}>
-
- {t('monitoring.codex_inspection_settings_group_strategy')} -
+
{t('monitoring.codex_inspection_settings_group_strategy')}
-
- handleSettingsDraftChange('targetType', event.target.value)} - placeholder={DEFAULT_CODEX_INSPECTION_SETTINGS.targetType} - /> -
-
- handleSettingsDraftChange('usedPercentThreshold', event.target.value)} - min={0} - max={100} - step={0.1} - /> -
-
- handleSettingsDraftChange('sampleSize', event.target.value)} - min={0} - step={1} - /> -
+
handleSettingsDraftChange('targetType', event.target.value)} placeholder={DEFAULT_CODEX_INSPECTION_SETTINGS.targetType} />
+
handleSettingsDraftChange('usedPercentThreshold', event.target.value)} min={0} max={100} step={0.1} />
+
handleSettingsDraftChange('sampleSize', event.target.value)} min={0} step={1} />
-
-
- {t('monitoring.codex_inspection_settings_group_concurrency')} -
+
{t('monitoring.codex_inspection_settings_group_concurrency')}
-
- handleSettingsDraftChange('workers', event.target.value)} - min={1} - step={1} - /> -
-
- handleSettingsDraftChange('deleteWorkers', event.target.value)} - min={1} - step={1} - /> -
-
- handleSettingsDraftChange('timeout', event.target.value)} - min={1} - step={100} - /> -
-
- handleSettingsDraftChange('retries', event.target.value)} - min={0} - step={1} - /> -
-
- handleSettingsDraftChange('userAgent', event.target.value)} - placeholder={DEFAULT_CODEX_INSPECTION_SETTINGS.userAgent} - /> -
+
handleSettingsDraftChange('workers', event.target.value)} min={1} step={1} />
+
handleSettingsDraftChange('deleteWorkers', event.target.value)} min={1} step={1} />
+
handleSettingsDraftChange('timeout', event.target.value)} min={1} step={100} />
+
handleSettingsDraftChange('retries', event.target.value)} min={0} step={1} />
+
handleSettingsDraftChange('userAgent', event.target.value)} placeholder={DEFAULT_CODEX_INSPECTION_SETTINGS.userAgent} />
-
-
- {t('monitoring.codex_inspection_settings_group_auto')} -
+
自定义巡检账号
+
+ + + + {targetAccounts.map((item) => ( + + + + + + + ))} + +
选择账号文件auth_index
toggleTargetKey(item.fileName)} />{item.displayAccount}{item.fileName}{item.authIndex || '--'}
+
+
+
+
{t('monitoring.codex_inspection_settings_group_auto')}
-
- -
+

{t('monitoring.codex_inspection_settings_auto_execute_actions_hint')}

@@ -1257,19 +559,16 @@ export function CodexInspectionPage() {
-
- - - + + +
+ + setResponseModalText('')} title={responseModalTitle || '\u54cd\u5e94\u8be6\u60c5'} width={900} className={styles.settingsModal}> +
{responseModalText}
+
); } diff --git a/src/pages/MonitoringCenterPage.tsx b/src/pages/MonitoringCenterPage.tsx index 353cba376..d8d0f8886 100644 --- a/src/pages/MonitoringCenterPage.tsx +++ b/src/pages/MonitoringCenterPage.tsx @@ -32,6 +32,7 @@ import { } from '@/components/ui/icons'; import { buildAccountRows, + buildApiKeyRows, buildMonitoringSummary, buildRealtimeMonitorRows, useMonitoringData, @@ -122,6 +123,11 @@ const DEFAULT_ACCOUNT_SORT = { direction: 'desc', } as const; +const DEFAULT_API_KEY_SORT = { + key: 'lastSeenAt', + direction: 'desc', +} as const; + type StatusFilter = 'all' | 'success' | 'failed'; type PanelProps = { @@ -213,6 +219,13 @@ type AccountSortState = { direction: AccountSortDirection; }; +type ApiKeySortKey = AccountSortKey; +type ApiKeySortDirection = AccountSortDirection; +type ApiKeySortState = { + key: ApiKeySortKey; + direction: ApiKeySortDirection; +}; + type AccountOverviewColumn = { key: string; label: string; @@ -430,6 +443,43 @@ const getAccountSortValue = (row: MonitoringAccountRow, key: AccountSortKey) => } }; +const getApiKeySortValue = ( + row: { + totalCalls: number; + successCalls: number; + failureCalls: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + totalCost: number; + lastSeenAt: number; + }, + key: ApiKeySortKey +) => { + switch (key) { + case 'totalCalls': + return row.totalCalls; + case 'successCalls': + return row.successCalls; + case 'failureCalls': + return row.failureCalls; + case 'totalTokens': + return row.totalTokens; + case 'inputTokens': + return row.inputTokens; + case 'outputTokens': + return row.outputTokens; + case 'cachedTokens': + return row.cachedTokens; + case 'totalCost': + return row.totalCost; + case 'lastSeenAt': + default: + return row.lastSeenAt; + } +}; + const compareAccountRowsByDefault = (left: MonitoringAccountRow, right: MonitoringAccountRow) => right.lastSeenAt - left.lastSeenAt || right.totalCalls - left.totalCalls || @@ -1093,6 +1143,7 @@ export function MonitoringCenterPage() { {} ); const [accountSort, setAccountSort] = useState(DEFAULT_ACCOUNT_SORT); + const [apiKeySort, setApiKeySort] = useState(DEFAULT_API_KEY_SORT); const [accountPage, setAccountPage] = useState(1); const [accountPageSize, setAccountPageSize] = useState(DEFAULT_ACCOUNT_PAGE_SIZE); const [realtimePage, setRealtimePage] = useState(1); @@ -1151,6 +1202,7 @@ export function MonitoringCenterPage() { const { usage, + apiKeyMap, loading: usageLoading, error: usageError, lastRefreshedAt, @@ -1171,6 +1223,7 @@ export function MonitoringCenterPage() { refreshMeta, } = useMonitoringData({ usage, + apiKeyMap, config, modelPrices, timeRange, @@ -1317,6 +1370,7 @@ export function MonitoringCenterPage() { const scopedSummary = useMemo(() => buildMonitoringSummary(scopedStatsRows), [scopedStatsRows]); const accountRows = useMemo(() => buildAccountRows(scopedRows), [scopedRows]); + const apiKeyRows = useMemo(() => buildApiKeyRows(scopedRows), [scopedRows]); const sortedAccountRows = useMemo(() => { const directionFactor = accountSort.direction === 'desc' ? -1 : 1; @@ -1330,6 +1384,23 @@ export function MonitoringCenterPage() { return compareAccountRowsByDefault(left, right); }); }, [accountRows, accountSort]); + const sortedApiKeyRows = useMemo(() => { + const directionFactor = apiKeySort.direction === 'desc' ? -1 : 1; + + return [...apiKeyRows].sort((left, right) => { + const valueDiff = + getApiKeySortValue(left, apiKeySort.key) - getApiKeySortValue(right, apiKeySort.key); + if (valueDiff !== 0) { + return valueDiff * directionFactor; + } + return ( + right.lastSeenAt - left.lastSeenAt || + right.totalCalls - left.totalCalls || + right.totalCost - left.totalCost + ); + }); + }, [apiKeyRows, apiKeySort]); + const groupedRealtimeRows = useMemo( () => buildRealtimeMonitorRows(scopedStatsRows), [scopedStatsRows] @@ -1434,6 +1505,27 @@ export function MonitoringCenterPage() { [t] ); + const apiKeyOverviewColumns = useMemo( + () => [ + { key: 'api-key', label: t('common.api_key') }, + { key: 'total-calls', label: t('monitoring.total_calls'), sortKey: 'totalCalls' }, + { key: 'success-calls', label: t('monitoring.success_calls'), sortKey: 'successCalls' }, + { key: 'failure-calls', label: t('monitoring.failure_calls'), sortKey: 'failureCalls' }, + { key: 'total-tokens', label: t('monitoring.total_tokens'), sortKey: 'totalTokens' }, + { key: 'input-tokens', label: t('monitoring.input_tokens'), sortKey: 'inputTokens' }, + { key: 'output-tokens', label: t('monitoring.output_tokens'), sortKey: 'outputTokens' }, + { key: 'cached-tokens', label: t('monitoring.cached_tokens'), sortKey: 'cachedTokens' }, + { key: 'estimated-cost', label: t('monitoring.estimated_cost'), sortKey: 'totalCost' }, + { + key: 'latest-request-time', + label: t('monitoring.latest_request_time'), + sortKey: 'lastSeenAt', + }, + ], + [t] + ); + + const primarySummaryCards: SummaryCardProps[] = [ { label: t('monitoring.total_calls'), @@ -1726,6 +1818,20 @@ export function MonitoringCenterPage() { ); }, []); + const handleApiKeySort = useCallback((key: ApiKeySortKey) => { + setApiKeySort((previous) => + previous.key === key + ? { + key, + direction: previous.direction === 'desc' ? 'asc' : 'desc', + } + : { + key, + direction: 'desc', + } + ); + }, []); + const handlePriceModelChange = useCallback( (value: string) => { setPriceModel(value); @@ -2279,6 +2385,102 @@ export function MonitoringCenterPage() { />
+ +
+ + + {apiKeyOverviewColumns.map((column) => ( + + ))} + + + + {apiKeyOverviewColumns.map((column) => { + if (!column.sortKey) { + return ; + } + + const isActive = apiKeySort.key === column.sortKey; + const SortIcon = isActive + ? apiKeySort.direction === 'desc' + ? IconChevronDown + : IconChevronUp + : null; + + return ( + + ); + })} + + + + {sortedApiKeyRows.map((row) => ( + + + + + + + + + + + + + ))} + {sortedApiKeyRows.length === 0 ? ( + + + + ) : null} + +
{column.label} + +
+
+ {row.apiKeyMasked} + + {row.accounts.slice(0, 2).join(', ')} + {row.accounts.length > 2 ? ` +${row.accounts.length - 2}` : ''} + +
+
{formatCompactNumber(row.totalCalls)}{formatCompactNumber(row.successCalls)} 0 ? styles.badText : undefined}> + {formatCompactNumber(row.failureCalls)} + {formatCompactNumber(row.totalTokens)}{formatCompactNumber(row.inputTokens)}{formatCompactNumber(row.outputTokens)}{formatCompactNumber(row.cachedTokens)}{hasPrices ? formatUsd(row.totalCost) : '--'}{new Date(row.lastSeenAt).toLocaleString(i18n.language)}
+
+ {hasSearchFilter ? t('monitoring.no_filtered_data') : t('monitoring.no_data')} +
+
+
+
+ `${normalizeUsageServiceBase(base).replace(/\/+$/, '')}${path}`; +const authHeaders = (managementKey?: string) => managementKey ? { Authorization: `Bearer ${managementKey}` } : undefined; +const TIMEOUT = 30 * 1000; + +export const codexInspectionApi = { + start: (base: string, managementKey: string, payload: CodexInspectionStartRequest) => + axios.post<{ run: CodexInspectionRunRecord }>(buildUrl(base, '/v0/management/codex-inspection/runs'), payload, { timeout: TIMEOUT, headers: authHeaders(managementKey) }).then((r) => r.data), + listRuns: (base: string, managementKey: string) => + axios.get<{ runs: CodexInspectionRunRecord[] }>(buildUrl(base, '/v0/management/codex-inspection/runs'), { timeout: TIMEOUT, headers: authHeaders(managementKey) }).then((r) => r.data), + getLatest: (base: string, managementKey: string) => + axios.get(buildUrl(base, '/v0/management/codex-inspection/runs/latest'), { timeout: TIMEOUT, headers: authHeaders(managementKey) }).then((r) => r.data), + getRun: (base: string, managementKey: string, runId: string) => + axios.get(buildUrl(base, `/v0/management/codex-inspection/runs/${encodeURIComponent(runId)}`), { timeout: TIMEOUT, headers: authHeaders(managementKey) }).then((r) => r.data), + pause: (base: string, managementKey: string, runId: string) => + axios.post(buildUrl(base, `/v0/management/codex-inspection/runs/${encodeURIComponent(runId)}/pause`), {}, { timeout: TIMEOUT, headers: authHeaders(managementKey) }).then((r) => r.data), + stop: (base: string, managementKey: string, runId: string) => + axios.post(buildUrl(base, `/v0/management/codex-inspection/runs/${encodeURIComponent(runId)}/stop`), {}, { timeout: TIMEOUT, headers: authHeaders(managementKey) }).then((r) => r.data), +}; diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 69acada9f..af0b2d406 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -14,3 +14,4 @@ export * from './version'; export * from './models'; export * from './transformers'; export * from './vertex'; +export * from './codexInspection'; diff --git a/src/services/api/usageService.ts b/src/services/api/usageService.ts index 81de185f8..fcae3d3e7 100644 --- a/src/services/api/usageService.ts +++ b/src/services/api/usageService.ts @@ -31,6 +31,16 @@ export interface UsageServiceStatus { collector?: UsageServiceCollectorStatus; } +export interface UsageServiceApiKeyMapItem { + apiKeyHash: string; + apiKeyLabel: string; + apiKeyMasked: string; +} + +export interface UsageServiceApiKeyMapResponse { + items: UsageServiceApiKeyMapItem[]; +} + export interface UsageServiceSetupRequest { cpaBaseUrl: string; managementKey: string; @@ -141,6 +151,20 @@ export const usageServiceApi = { return response.data; }, + getApiKeyMap: async ( + base: string, + managementKey?: string + ): Promise => { + const response = await axios.get( + buildUrl(base, '/v0/management/api-key-map'), + { + timeout: USAGE_SERVICE_TIMEOUT_MS, + headers: authHeaders(managementKey), + } + ); + return response.data; + }, + getModelPrices: async ( base: string, managementKey?: string diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 0a2b4d7a5..75ee2332d 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -23,7 +23,10 @@ export interface UsageTokens { export interface UsageDetail { timestamp: string; source: string; + provider?: string; + auth_type?: string; auth_index: string | number | null; + api_key_hash?: string; latency_ms?: number; tokens: UsageTokens; failed: boolean; @@ -243,6 +246,24 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] { detailRaw.authIndex ?? detailRaw.AuthIndex ?? null) as UsageDetail['auth_index'], + provider: + typeof detailRaw.provider === 'string' + ? detailRaw.provider + : typeof detailRaw.type === 'string' + ? detailRaw.type + : undefined, + auth_type: + typeof detailRaw.auth_type === 'string' + ? detailRaw.auth_type + : typeof detailRaw.authType === 'string' + ? detailRaw.authType + : undefined, + api_key_hash: + typeof detailRaw.api_key_hash === 'string' + ? detailRaw.api_key_hash + : typeof detailRaw.apiKeyHash === 'string' + ? detailRaw.apiKeyHash + : undefined, latency_ms: latencyMs ?? undefined, tokens: readTokens(detailRaw), failed: detailRaw.failed === true, @@ -295,6 +316,24 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail detailRaw.authIndex ?? detailRaw.AuthIndex ?? null) as UsageDetail['auth_index'], + provider: + typeof detailRaw.provider === 'string' + ? detailRaw.provider + : typeof detailRaw.type === 'string' + ? detailRaw.type + : undefined, + auth_type: + typeof detailRaw.auth_type === 'string' + ? detailRaw.auth_type + : typeof detailRaw.authType === 'string' + ? detailRaw.authType + : undefined, + api_key_hash: + typeof detailRaw.api_key_hash === 'string' + ? detailRaw.api_key_hash + : typeof detailRaw.apiKeyHash === 'string' + ? detailRaw.apiKeyHash + : undefined, latency_ms: latencyMs ?? undefined, tokens: readTokens(detailRaw), failed: detailRaw.failed === true, diff --git a/usage-service/cmd/cpa-manager/main.go b/usage-service/cmd/cpa-manager/main.go index d83c4dc0c..a36c2efba 100644 --- a/usage-service/cmd/cpa-manager/main.go +++ b/usage-service/cmd/cpa-manager/main.go @@ -10,6 +10,7 @@ import ( "time" "github.com/seakee/cpa-manager/usage-service/internal/collector" + "github.com/seakee/cpa-manager/usage-service/internal/codexinspection" "github.com/seakee/cpa-manager/usage-service/internal/config" "github.com/seakee/cpa-manager/usage-service/internal/httpapi" "github.com/seakee/cpa-manager/usage-service/internal/store" @@ -27,6 +28,10 @@ func main() { defer db.Close() manager := collector.NewManager(cfg, db) + codexManager := codexinspection.NewManager(db, codexinspection.NewHTTPRunner(codexinspection.RunnerDeps{ + CPAUpstreamURL: cfg.CPAUpstreamURL, + ManagementKey: cfg.ManagementKey, + })) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -50,7 +55,7 @@ func main() { server := &http.Server{ Addr: cfg.HTTPAddr, - Handler: httpapi.New(cfg, db, manager).Handler(), + Handler: httpapi.New(cfg, db, manager, codexManager).Handler(), ReadHeaderTimeout: 10 * time.Second, } diff --git a/usage-service/internal/codexinspection/manager.go b/usage-service/internal/codexinspection/manager.go new file mode 100644 index 000000000..1d5111611 --- /dev/null +++ b/usage-service/internal/codexinspection/manager.go @@ -0,0 +1,336 @@ +package codexinspection + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/seakee/cpa-manager/usage-service/internal/store" +) + +type LogLevel string + +type RunStatus string + +type Action string + +type ScopeType string + +const ( + LogInfo LogLevel = "info" + LogSuccess LogLevel = "success" + LogWarning LogLevel = "warning" + LogError LogLevel = "error" + + StatusIdle RunStatus = "idle" + StatusRunning RunStatus = "running" + StatusPaused RunStatus = "paused" + StatusStopped RunStatus = "stopped" + StatusCompleted RunStatus = "completed" + StatusFailed RunStatus = "failed" + + ActionKeep Action = "keep" + ActionDelete Action = "delete" + ActionDisable Action = "disable" + ActionEnable Action = "enable" + + ScopeAll ScopeType = "all" + ScopeSelected ScopeType = "selected" +) + +type Settings struct { + TargetType string `json:"targetType"` + Workers int `json:"workers"` + DeleteWorkers int `json:"deleteWorkers"` + Timeout int `json:"timeout"` + Retries int `json:"retries"` + UserAgent string `json:"userAgent"` + UsedPercentThreshold float64 `json:"usedPercentThreshold"` + SampleSize int `json:"sampleSize"` + AutoExecuteActions bool `json:"autoExecuteActions"` + SelectedAccounts []string `json:"selectedAccounts,omitempty"` +} + +type ProgressSummary struct { + TotalFiles int `json:"totalFiles"` + ProbeSetCount int `json:"probeSetCount"` + SampledCount int `json:"sampledCount"` + DeleteCount int `json:"deleteCount"` + DisableCount int `json:"disableCount"` + EnableCount int `json:"enableCount"` + KeepCount int `json:"keepCount"` +} + +type ProgressSnapshot struct { + Total int `json:"total"` + Completed int `json:"completed"` + InFlight int `json:"inFlight"` + Pending int `json:"pending"` + Percent int `json:"percent"` + Status RunStatus `json:"status"` + Summary ProgressSummary `json:"summary"` + StartedAt int64 `json:"startedAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +type Account struct { + Key string `json:"key"` + FileName string `json:"fileName"` + DisplayAccount string `json:"displayAccount"` + AuthIndex string `json:"authIndex,omitempty"` + AccountID string `json:"accountId,omitempty"` + Provider string `json:"provider,omitempty"` + Disabled bool `json:"disabled"` + Status string `json:"status,omitempty"` + State string `json:"state,omitempty"` +} + +type Result struct { + Account + Action Action `json:"action"` + ActionReason string `json:"actionReason,omitempty"` + StatusCode *int `json:"statusCode,omitempty"` + UsedPercent *float64 `json:"usedPercent,omitempty"` + IsQuota bool `json:"isQuota"` + Error string `json:"error,omitempty"` + ResponseBodyText string `json:"responseBodyText,omitempty"` + ResponseBodyJSON string `json:"responseBodyJson,omitempty"` + ResponseHeadersJSON string `json:"responseHeadersJson,omitempty"` + UpdatedAtMS int64 `json:"updatedAtMs"` +} + +type Summary struct { + TotalFiles int `json:"totalFiles"` + ProbeSetCount int `json:"probeSetCount"` + SampledCount int `json:"sampledCount"` + DisabledCount int `json:"disabledCount"` + EnabledCount int `json:"enabledCount"` + DeleteCount int `json:"deleteCount"` + DisableCount int `json:"disableCount"` + EnableCount int `json:"enableCount"` + KeepCount int `json:"keepCount"` + UsedPercentThreshold float64 `json:"usedPercentThreshold"` + Sampled bool `json:"sampled"` + PlannedActionPreview []string `json:"plannedActionPreview"` +} + +type RunView struct { + Run store.CodexInspectionRun `json:"run"` + Progress ProgressSnapshot `json:"progress"` + Summary Summary `json:"summary"` + Results []store.CodexInspectionResultRow `json:"results,omitempty"` + Logs []store.CodexInspectionLogRow `json:"logs,omitempty"` +} + +type Runner interface { + Execute(ctx context.Context, runID string, settings Settings, logf func(LogLevel, string), persist func([]Result, ProgressSnapshot, Summary) error) error +} + +type job struct { + cancel context.CancelFunc + mu sync.RWMutex + status RunStatus +} + +type Manager struct { + store *store.Store + runner Runner + mu sync.RWMutex + writeMu sync.Mutex + jobs map[string]*job +} + +func NewManager(db *store.Store, runner Runner) *Manager { + return &Manager{store: db, runner: runner, jobs: map[string]*job{}} +} + +func BuildRunName(ts time.Time) string { + return ts.Format("200601021504") +} + +func BuildRunID(ts time.Time) string { + return ts.Format("20060102150405") +} + +func buildSummary(totalFiles, probeSetCount, sampledCount int, settings Settings, results []Result) Summary { + deleteCount := 0 + disableCount := 0 + enableCount := 0 + keepCount := 0 + disabledCount := 0 + enabledCount := 0 + preview := make([]string, 0) + for _, item := range results { + if item.Disabled { disabledCount++ } else { enabledCount++ } + switch item.Action { + case ActionDelete: + deleteCount++ + case ActionDisable: + disableCount++ + case ActionEnable: + enableCount++ + default: + keepCount++ + } + if item.Action != ActionKeep && len(preview) < 10 { + preview = append(preview, fmt.Sprintf("%s -> %s", item.DisplayAccount, item.Action)) + } + } + return Summary{ + TotalFiles: totalFiles, ProbeSetCount: probeSetCount, SampledCount: sampledCount, + DisabledCount: disabledCount, EnabledCount: enabledCount, + DeleteCount: deleteCount, DisableCount: disableCount, EnableCount: enableCount, KeepCount: keepCount, + UsedPercentThreshold: settings.UsedPercentThreshold, + Sampled: settings.SampleSize > 0 && settings.SampleSize < probeSetCount, + PlannedActionPreview: preview, + } +} + +func buildProgress(total, completed, inflight int, status RunStatus, startedAt int64, results []Result, totalFiles, probeSetCount, sampledCount int) (ProgressSnapshot, Summary) { + now := time.Now().UnixMilli() + pending := total - completed - inflight + if pending < 0 { pending = 0 } + percent := 0 + if total > 0 { percent = int(float64(completed) / float64(total) * 100) } + summary := buildSummary(totalFiles, probeSetCount, sampledCount, Settings{}, results) + summary.TotalFiles = totalFiles + summary.ProbeSetCount = probeSetCount + summary.SampledCount = sampledCount + return ProgressSnapshot{ + Total: total, Completed: completed, InFlight: inflight, Pending: pending, + Percent: percent, Status: status, + Summary: ProgressSummary{ + TotalFiles: summary.TotalFiles, + ProbeSetCount: summary.ProbeSetCount, + SampledCount: summary.SampledCount, + DeleteCount: summary.DeleteCount, + DisableCount: summary.DisableCount, + EnableCount: summary.EnableCount, + KeepCount: summary.KeepCount, + }, + StartedAt: startedAt, + UpdatedAt: now, + }, summary +} + +func (m *Manager) SaveLog(ctx context.Context, runID string, level LogLevel, message string) error { + m.writeMu.Lock() + defer m.writeMu.Unlock() + return m.store.AddCodexInspectionLog(ctx, store.CodexInspectionLogRow{RunID: runID, Level: string(level), Message: message, CreatedAtMS: time.Now().UnixMilli()}) +} + +func toStoreRows(runID string, results []Result) []store.CodexInspectionResultRow { + rows := make([]store.CodexInspectionResultRow, 0, len(results)) + for _, item := range results { + rows = append(rows, store.CodexInspectionResultRow{ + RunID: runID, AccountKey: item.Key, FileName: item.FileName, DisplayAccount: item.DisplayAccount, + AuthIndex: item.AuthIndex, AccountID: item.AccountID, Provider: item.Provider, Disabled: item.Disabled, + Status: item.Status, State: item.State, Action: string(item.Action), ActionReason: item.ActionReason, + StatusCode: item.StatusCode, UsedPercent: item.UsedPercent, IsQuota: item.IsQuota, Error: item.Error, + ResponseBodyText: item.ResponseBodyText, ResponseBodyJSON: item.ResponseBodyJSON, ResponseHeadersJSON: item.ResponseHeadersJSON, + UpdatedAtMS: item.UpdatedAtMS, + }) + } + return rows +} + +func (m *Manager) Start(ctx context.Context, settings Settings) (store.CodexInspectionRun, error) { + now := time.Now() + runID := BuildRunID(now) + runName := BuildRunName(now) + selectedTargetsJSON := "" + if len(settings.SelectedAccounts) > 0 { + buf, _ := json.Marshal(settings.SelectedAccounts) + selectedTargetsJSON = string(buf) + } + run := store.CodexInspectionRun{ + RunID: runID, RunName: runName, Status: string(StatusRunning), TargetType: settings.TargetType, + Workers: settings.Workers, DeleteWorkers: settings.DeleteWorkers, Timeout: settings.Timeout, Retries: settings.Retries, + UserAgent: settings.UserAgent, UsedPercentThreshold: settings.UsedPercentThreshold, SampleSize: settings.SampleSize, + AutoExecuteActions: settings.AutoExecuteActions, + TargetScopeType: func() string { if len(settings.SelectedAccounts) > 0 { return string(ScopeSelected) }; return string(ScopeAll) }(), + SelectedTargetsJSON: selectedTargetsJSON, SummaryJSON: "", StartedAtMS: now.UnixMilli(), UpdatedAtMS: now.UnixMilli(), + } + if err := m.store.SaveCodexInspectionRun(ctx, run); err != nil { return store.CodexInspectionRun{}, err } + jobCtx, cancel := context.WithCancel(context.Background()) + j := &job{cancel: cancel, status: StatusRunning} + m.mu.Lock(); m.jobs[runID] = j; m.mu.Unlock() + go m.run(jobCtx, run, settings) + return run, nil +} + +func (m *Manager) run(ctx context.Context, run store.CodexInspectionRun, settings Settings) { + results := make([]Result, 0) + lastPersistAt := int64(0) + persist := func(items []Result, progress ProgressSnapshot, summary Summary) error { + if progress.Status == StatusRunning && progress.Completed < progress.Total && lastPersistAt > 0 && progress.UpdatedAt-lastPersistAt < 500 { + return nil + } + m.writeMu.Lock() + defer m.writeMu.Unlock() + rows := toStoreRows(run.RunID, items) + if err := m.store.SaveCodexInspectionResults(context.Background(), rows); err != nil { return err } + summaryJSON, _ := json.Marshal(summary) + progressJSON, _ := json.Marshal(progress) + run.SummaryJSON = string(summaryJSON) + run.ProgressJSON = string(progressJSON) + run.UpdatedAtMS = progress.UpdatedAt + run.Status = string(progress.Status) + if progress.Status == StatusCompleted || progress.Status == StatusStopped || progress.Status == StatusFailed { + finished := progress.UpdatedAt + run.FinishedAtMS = &finished + } + if err := m.store.SaveCodexInspectionRun(context.Background(), run); err != nil { return err } + lastPersistAt = progress.UpdatedAt + return nil + } + logf := func(level LogLevel, msg string) { + _ = m.SaveLog(context.Background(), run.RunID, level, msg) + } + err := m.runner.Execute(ctx, run.RunID, settings, logf, func(next []Result, progress ProgressSnapshot, summary Summary) error { + results = append(results[:0], next...) + return persist(results, progress, summary) + }) + m.mu.Lock() + delete(m.jobs, run.RunID) + m.mu.Unlock() + if err != nil { + _ = m.SaveLog(context.Background(), run.RunID, LogError, err.Error()) + run.Status = string(StatusFailed) + now := time.Now().UnixMilli() + run.UpdatedAtMS = now + run.FinishedAtMS = &now + m.writeMu.Lock() + _ = m.store.SaveCodexInspectionRun(context.Background(), run) + m.writeMu.Unlock() + } +} + +func (m *Manager) Stop(runID string) { + m.mu.RLock(); j := m.jobs[runID]; m.mu.RUnlock() + if j != nil { j.cancel() } +} + +func (m *Manager) IsRunning(runID string) bool { + m.mu.RLock(); defer m.mu.RUnlock() + _, ok := m.jobs[runID] + return ok +} + +func NormalizeSelectedAccounts(values []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { continue } + if _, ok := seen[v]; ok { continue } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} diff --git a/usage-service/internal/codexinspection/runner.go b/usage-service/internal/codexinspection/runner.go new file mode 100644 index 000000000..fef2bdb5e --- /dev/null +++ b/usage-service/internal/codexinspection/runner.go @@ -0,0 +1,397 @@ +package codexinspection + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "sort" + "strings" + "sync" + "time" +) + +type RunnerDeps struct { + CPAUpstreamURL string + ManagementKey string +} + +type HTTPRunner struct { + deps RunnerDeps +} + +type authFileItem struct { + Name string `json:"name"` + Type string `json:"type"` + Provider string `json:"provider"` + AuthIndex string `json:"auth_index"` + Disabled bool `json:"disabled"` + Status string `json:"status"` + State string `json:"state"` + Account string `json:"account"` + Email string `json:"email"` + Label string `json:"label"` + IDToken map[string]any `json:"id_token"` +} + +type authFilesResponse struct { + Files []authFileItem `json:"files"` +} + +type apiCallResponse struct { + StatusCode int `json:"status_code"` + Header map[string][]string `json:"header"` + Body any `json:"body"` +} + +type codexUsageWindow struct { + UsedPercent *float64 `json:"used_percent"` + UsedPercentCamel *float64 `json:"usedPercent"` + LimitWindowSeconds *float64 `json:"limit_window_seconds"` + LimitWindowCamel *float64 `json:"limitWindowSeconds"` +} + +type codexRateLimitInfo struct { + Allowed *bool `json:"allowed"` + LimitReached *bool `json:"limit_reached"` + LimitReachedCamel *bool `json:"limitReached"` + PrimaryWindow *codexUsageWindow `json:"primary_window"` + PrimaryWindowCamel *codexUsageWindow `json:"primaryWindow"` + SecondaryWindow *codexUsageWindow `json:"secondary_window"` + SecondaryWindowCamel *codexUsageWindow `json:"secondaryWindow"` +} + +type codexUsagePayload struct { + RateLimit *codexRateLimitInfo `json:"rate_limit"` + RateLimitCamel *codexRateLimitInfo `json:"rateLimit"` +} + +const ( + codexUsageURL = "https://chatgpt.com/backend-api/wham/usage" + fiveHourSeconds = 18000 + weekSeconds = 604800 +) + +var quotaBodyPatterns = []string{"quota exhausted", "limit reached", "usage_limit_reached", "payment_required"} + +func NewHTTPRunner(deps RunnerDeps) *HTTPRunner { return &HTTPRunner{deps: deps} } + +func (r *HTTPRunner) Execute(ctx context.Context, runID string, settings Settings, logf func(LogLevel, string), persist func([]Result, ProgressSnapshot, Summary) error) error { + files, err := r.fetchAuthFiles(ctx) + if err != nil { return err } + accounts := make([]Account, 0, len(files)) + for _, file := range files { + accounts = append(accounts, toAccount(file)) + } + probeSet := filterAccounts(accounts, settings.TargetType, NormalizeSelectedAccounts(settings.SelectedAccounts)) + sampled := pickSample(probeSet, settings.SampleSize) + results := make([]Result, 0, len(sampled)) + resultsMu := sync.Mutex{} + completed := 0 + inflight := 0 + status := StatusRunning + startedAt := time.Now().UnixMilli() + persistSnapshot := func() error { + resultsMu.Lock() + cloned := append([]Result(nil), results...) + completedLocal := completed + inflightLocal := inflight + resultsMu.Unlock() + prog, summary := buildProgress(len(sampled), completedLocal, inflightLocal, status, startedAt, cloned, len(files), len(probeSet), len(sampled)) + summary = buildSummary(len(files), len(probeSet), len(sampled), settings, cloned) + return persist(cloned, prog, summary) + } + logf(LogInfo, fmt.Sprintf("\u52a0\u8f7d\u8ba4\u8bc1\u6587\u4ef6\u5217\u8868\uff0c\u76ee\u6807\u7c7b\u578b\uff1a%s", settings.TargetType)) + logf(LogInfo, fmt.Sprintf("\u5de1\u68c0\u96c6\u5408 %d \u4e2a\u8d26\u53f7\uff0c\u672c\u6b21\u63a2\u6d4b %d \u4e2a\u8d26\u53f7", len(probeSet), len(sampled))) + if err := persistSnapshot(); err != nil { return err } + if len(sampled) == 0 { + status = StatusCompleted + return persistSnapshot() + } + sem := make(chan struct{}, max(1, settings.Workers)) + wg := sync.WaitGroup{} + errCh := make(chan error, len(sampled)) + for _, account := range sampled { + select { + case <-ctx.Done(): + status = StatusStopped + _ = persistSnapshot() + return ctx.Err() + default: + } + sem <- struct{}{} + wg.Add(1) + resultsMu.Lock(); inflight++; resultsMu.Unlock() + _ = persistSnapshot() + go func(account Account) { + defer func(){ <-sem; wg.Done() }() + res := r.inspectSingleAccount(ctx, account, settings, logf) + resultsMu.Lock() + results = append(results, res) + completed++ + inflight-- + resultsMu.Unlock() + if err := persistSnapshot(); err != nil { errCh <- err } + }(account) + } + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + status = StatusFailed + _ = persistSnapshot() + return err + } + } + if ctx.Err() != nil { + status = StatusStopped + _ = persistSnapshot() + return ctx.Err() + } + status = StatusCompleted + return persistSnapshot() +} + +func max(a, b int) int { if a > b { return a }; return b } + +func pickSample(items []Account, sampleSize int) []Account { + if sampleSize <= 0 || sampleSize >= len(items) { return append([]Account(nil), items...) } + return append([]Account(nil), items[:sampleSize]...) +} + +func filterAccounts(items []Account, targetType string, selected []string) []Account { + selectedSet := map[string]struct{}{} + for _, value := range selected { selectedSet[strings.TrimSpace(value)] = struct{}{} } + out := make([]Account, 0) + for _, item := range items { + if strings.ToLower(strings.TrimSpace(item.Provider)) != strings.ToLower(strings.TrimSpace(targetType)) { continue } + if len(selectedSet) > 0 { + if _, ok := selectedSet[item.FileName]; ok { out = append(out, item); continue } + if item.AuthIndex != "" { if _, ok := selectedSet[item.AuthIndex]; ok { out = append(out, item); continue } } + if item.DisplayAccount != "" { if _, ok := selectedSet[item.DisplayAccount]; ok { out = append(out, item); continue } } + continue + } + out = append(out, item) + } + sort.Slice(out, func(i,j int) bool { return out[i].FileName < out[j].FileName }) + return out +} + +func toAccount(file authFileItem) Account { + display := firstNonEmpty(file.Account, file.Email, file.Label, file.Name, "-") + accountID := "" + if file.IDToken != nil { + accountID = firstNonEmpty(asString(file.IDToken["chatgpt_account_id"]), asString(file.IDToken["chatgptAccountId"])) + } + provider := firstNonEmpty(file.Provider, file.Type, "unknown") + return Account{ + Key: firstNonEmpty(file.Name, display), FileName: file.Name, DisplayAccount: display, AuthIndex: file.AuthIndex, + AccountID: accountID, Provider: provider, Disabled: file.Disabled, Status: file.Status, State: file.State, + } +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } + return "" +} + +func asString(v any) string { + if v == nil { return "" } + return strings.TrimSpace(fmt.Sprint(v)) +} + +func (r *HTTPRunner) fetchAuthFiles(ctx context.Context) ([]authFileItem, error) { + url := strings.TrimRight(r.deps.CPAUpstreamURL, "/") + "/v0/management/auth-files" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { return nil, err } + req.Header.Set("Authorization", "Bearer "+r.deps.ManagementKey) + res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { return nil, err } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { return nil, fmt.Errorf("auth-files request failed: %s", res.Status) } + var payload authFilesResponse + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { return nil, err } + return payload.Files, nil +} + +func (r *HTTPRunner) inspectSingleAccount(ctx context.Context, account Account, settings Settings, logf func(LogLevel, string)) Result { + now := time.Now().UnixMilli() + if account.AuthIndex == "" { + logf(LogWarning, fmt.Sprintf("%s \u7f3a\u5c11 auth_index\uff0c\u8df3\u8fc7\u63a2\u6d4b", account.DisplayAccount)) + return Result{Account: account, Action: ActionKeep, ActionReason: "\u7f3a\u5c11 auth_index\uff0c\u4fdd\u7559\u8d26\u53f7", Error: "\u7f3a\u5c11 auth_index", UpdatedAtMS: now} + } + headers := map[string]string{ + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": settings.UserAgent, + } + if account.AccountID != "" { headers["Chatgpt-Account-Id"] = account.AccountID } + payload := map[string]any{"authIndex": account.AuthIndex, "method": "GET", "url": codexUsageURL, "header": headers} + body, _ := json.Marshal(payload) + url := strings.TrimRight(r.deps.CPAUpstreamURL, "/") + "/v0/management/api-call" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { return Result{Account: account, Action: ActionKeep, ActionReason: "\u63a2\u6d4b\u5f02\u5e38\uff0c\u4fdd\u7559\u8d26\u53f7", Error: err.Error(), UpdatedAtMS: now} } + req.Header.Set("Authorization", "Bearer "+r.deps.ManagementKey) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: time.Duration(max(1, settings.Timeout)) * time.Millisecond} + res, err := client.Do(req) + if err != nil { + logf(LogWarning, fmt.Sprintf("%s \u63a2\u6d4b\u5f02\u5e38\uff0c\u4fdd\u7559\u8d26\u53f7\uff1a%s", account.DisplayAccount, err.Error())) + return Result{Account: account, Action: ActionKeep, ActionReason: "\u63a2\u6d4b\u5f02\u5e38\uff0c\u4fdd\u7559\u8d26\u53f7", Error: err.Error(), UpdatedAtMS: now} + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + var call apiCallResponse + if err := json.Unmarshal(raw, &call); err != nil { + logf(LogWarning, fmt.Sprintf("%s \u63a2\u6d4b\u5f02\u5e38\uff0c\u4fdd\u7559\u8d26\u53f7\uff1a%s", account.DisplayAccount, err.Error())) + return Result{Account: account, Action: ActionKeep, ActionReason: "\u63a2\u6d4b\u5f02\u5e38\uff0c\u4fdd\u7559\u8d26\u53f7", Error: err.Error(), UpdatedAtMS: now} + } + bodyText, bodyJSON := normalizeBody(call.Body) + headerJSON, _ := json.Marshal(call.Header) + payloadParsed := parseCodexUsagePayload(bodyText, call.Body) + rateLimit := payloadParsed + usedPercent := deriveUsedPercent(rateLimit) + quotaReached := isQuota(call.StatusCode, bodyText, rateLimit, settings.UsedPercentThreshold) + action, reason, isQuotaFlag := resolveDecision(account, call.StatusCode, rateLimit, usedPercent, quotaReached, settings.UsedPercentThreshold) + level := LogInfo + if action == ActionDelete { level = LogError } else if action == ActionDisable { level = LogWarning } else if action == ActionEnable { level = LogSuccess } + percentText := "--" + if usedPercent != nil { percentText = fmt.Sprintf("%.1f%%", *usedPercent) } +\tlogf(level, fmt.Sprintf("%s -> %s (HTTP %d \u00b7 \u5df2\u7528 %s)", account.DisplayAccount, action, call.StatusCode, percentText)) + return Result{Account: account, Action: action, ActionReason: reason, StatusCode: intPtr(call.StatusCode), UsedPercent: usedPercent, IsQuota: isQuotaFlag, Error: "", ResponseBodyText: bodyText, ResponseBodyJSON: bodyJSON, ResponseHeadersJSON: string(headerJSON), UpdatedAtMS: now} +} + +func intPtr(v int) *int { return &v } +func floatPtr(v float64) *float64 { return &v } + +func normalizeBody(body any) (string, string) { + if body == nil { return "", "" } + if s, ok := body.(string); ok { + trim := strings.TrimSpace(s) + if trim == "" { return s, "" } + if json.Valid([]byte(trim)) { return s, trim } + return s, "" + } + buf, err := json.Marshal(body) + if err != nil { return fmt.Sprint(body), "" } + return string(buf), string(buf) +} + +func parseCodexUsagePayload(bodyText string, body any) *codexRateLimitInfo { + var payload codexUsagePayload + if body != nil { + if m, ok := body.(map[string]any); ok { + buf, _ := json.Marshal(m) + if json.Unmarshal(buf, &payload) == nil { + if payload.RateLimit != nil { return payload.RateLimit } + if payload.RateLimitCamel != nil { return payload.RateLimitCamel } + } + } + } + trim := strings.TrimSpace(bodyText) + if trim != "" && json.Valid([]byte(trim)) { + if json.Unmarshal([]byte(trim), &payload) == nil { + if payload.RateLimit != nil { return payload.RateLimit } + if payload.RateLimitCamel != nil { return payload.RateLimitCamel } + } + } + return nil +} + +func getWindowUsedPercent(window *codexUsageWindow) *float64 { + if window == nil { return nil } + if window.UsedPercent != nil { return window.UsedPercent } + return window.UsedPercentCamel +} + +func getWindowSeconds(window *codexUsageWindow) *float64 { + if window == nil { return nil } + if window.LimitWindowSeconds != nil { return window.LimitWindowSeconds } + return window.LimitWindowCamel +} + +func getLimitWindows(rateLimit *codexRateLimitInfo) []*codexUsageWindow { + if rateLimit == nil { return nil } + out := make([]*codexUsageWindow,0,2) + if rateLimit.PrimaryWindow != nil { out = append(out, rateLimit.PrimaryWindow) } else if rateLimit.PrimaryWindowCamel != nil { out = append(out, rateLimit.PrimaryWindowCamel) } + if rateLimit.SecondaryWindow != nil { out = append(out, rateLimit.SecondaryWindow) } else if rateLimit.SecondaryWindowCamel != nil { out = append(out, rateLimit.SecondaryWindowCamel) } + return out +} + +func deriveUsedPercent(rateLimit *codexRateLimitInfo) *float64 { + maxValue := math.Inf(-1) + found := false + for _, window := range getLimitWindows(rateLimit) { + if v := getWindowUsedPercent(window); v != nil { if *v > maxValue { maxValue = *v }; found = true } + } + if !found { return nil } + return floatPtr(maxValue) +} + +func isRateLimitReached(rateLimit *codexRateLimitInfo) bool { + if rateLimit == nil { return false } + if rateLimit.Allowed != nil && !*rateLimit.Allowed { return true } + if rateLimit.LimitReached != nil && *rateLimit.LimitReached { return true } + if rateLimit.LimitReachedCamel != nil && *rateLimit.LimitReachedCamel { return true } + for _, window := range getLimitWindows(rateLimit) { + if v := getWindowUsedPercent(window); v != nil && *v >= 100 { return true } + } + return false +} + +func isQuota(statusCode int, bodyText string, rateLimit *codexRateLimitInfo, threshold float64) bool { + lower := strings.ToLower(bodyText) + if statusCode == 402 { return true } + for _, pat := range quotaBodyPatterns { if strings.Contains(lower, pat) { return true } } + if isRateLimitReached(rateLimit) { return true } + if usedPercent := deriveUsedPercent(rateLimit); usedPercent != nil && *usedPercent >= threshold { return true } + return false +} + +func pickClassifiedWindows(rateLimit *codexRateLimitInfo) (*codexUsageWindow, *codexUsageWindow) { + var five, weekly *codexUsageWindow + for _, w := range getLimitWindows(rateLimit) { + if sec := getWindowSeconds(w); sec != nil { + if int(*sec) == fiveHourSeconds && five == nil { five = w; continue } + if int(*sec) == weekSeconds && weekly == nil { weekly = w; continue } + } + } + windows := getLimitWindows(rateLimit) + if five == nil && len(windows) > 0 && windows[0] != weekly { five = windows[0] } + if weekly == nil && len(windows) > 1 && windows[1] != five { weekly = windows[1] } + return five, weekly +} + +func resolveDecision(account Account, statusCode int, rateLimit *codexRateLimitInfo, usedPercent *float64, isQuota bool, threshold float64) (Action, string, bool) { + five, weekly := pickClassifiedWindows(rateLimit) + if weekly != nil { + weeklyUsed := getWindowUsedPercent(weekly) + fiveUsed := getWindowUsedPercent(five) + if statusCode == 401 { return ActionDelete, "\u63a5\u53e3\u8fd4\u56de 401\uff0c\u5efa\u8bae\u5220\u9664\u5931\u6548\u8d26\u53f7", false } + if weeklyUsed != nil && *weeklyUsed >= threshold { + if account.Disabled { return ActionKeep, "\u5468\u989d\u5ea6\u8fbe\u5230\u9608\u503c\uff0c\u4f46\u8d26\u53f7\u5df2\u7981\u7528", true } + return ActionDisable, "\u5468\u989d\u5ea6\u8fbe\u5230\u9608\u503c\uff0c\u5efa\u8bae\u7981\u7528\u8d26\u53f7", true + } + if account.Disabled { + if fiveUsed != nil && *fiveUsed >= threshold { return ActionEnable, "5 \u5c0f\u65f6\u989d\u5ea6\u8fbe\u5230\u9608\u503c\uff0c\u4f46\u5468\u989d\u5ea6\u4ecd\u53ef\u7528\uff0c\u5efa\u8bae\u7acb\u5373\u542f\u7528\u8d26\u53f7", false } + return ActionEnable, "\u5468\u989d\u5ea6\u4ecd\u53ef\u7528\uff0c\u5efa\u8bae\u7acb\u5373\u542f\u7528\u8d26\u53f7", false + } + if fiveUsed != nil && *fiveUsed >= threshold { return ActionKeep, "5 \u5c0f\u65f6\u989d\u5ea6\u8fbe\u5230\u9608\u503c\uff0c\u4f46\u5468\u989d\u5ea6\u4ecd\u53ef\u7528\uff0c\u6682\u4e0d\u7981\u7528\u8d26\u53f7", false } + return ActionKeep, "\u5468\u989d\u5ea6\u4ecd\u53ef\u7528\uff0c\u65e0\u9700\u5904\u7406", false + } + overThreshold := usedPercent != nil && *usedPercent >= threshold + if statusCode == 401 { return ActionDelete, "\u63a5\u53e3\u8fd4\u56de 401\uff0c\u5efa\u8bae\u5220\u9664\u5931\u6548\u8d26\u53f7", false } + if isQuota || overThreshold { + if account.Disabled { + if overThreshold { return ActionKeep, "\u989d\u5ea6\u8d85\u9608\u503c\uff0c\u4f46\u8d26\u53f7\u5df2\u7981\u7528", isQuota } + return ActionKeep, "\u989d\u5ea6\u5df2\u8017\u5c3d\uff0c\u4f46\u8d26\u53f7\u5df2\u7981\u7528", isQuota + } + if overThreshold { return ActionDisable, "\u989d\u5ea6\u8d85\u9608\u503c\uff0c\u5efa\u8bae\u7981\u7528\u8d26\u53f7", isQuota } + return ActionDisable, "\u989d\u5ea6\u5df2\u8017\u5c3d\uff0c\u5efa\u8bae\u7981\u7528\u8d26\u53f7", isQuota + } + if statusCode == 200 && account.Disabled { return ActionEnable, "\u8d26\u53f7\u6062\u590d\u5065\u5eb7\uff0c\u5efa\u8bae\u91cd\u65b0\u542f\u7528", false } + return ActionKeep, "\u65e0\u9700\u5904\u7406", false +} diff --git a/usage-service/internal/config/config.go b/usage-service/internal/config/config.go index b1aaa2f35..c5d2308bf 100644 --- a/usage-service/internal/config/config.go +++ b/usage-service/internal/config/config.go @@ -37,6 +37,7 @@ type fileConfig struct { DataDir string `json:"dataDir,omitempty"` DBPath string `json:"dbPath,omitempty"` CPAUpstreamURL string `json:"cpaUpstreamUrl,omitempty"` + CPAManagementKey string `json:"cpaManagementKey,omitempty"` ManagementKeyFile string `json:"managementKeyFile,omitempty"` CollectorMode string `json:"collectorMode,omitempty"` Queue string `json:"queue,omitempty"` @@ -72,12 +73,16 @@ func Load() (Config, error) { if cfgFile.ManagementKeyFile != "" { managementKeyFile = resolveConfigPath(cfgFile.ManagementKeyFile, cfgDir) } + resolvedManagementKey := readSecret("CPA_MANAGEMENT_KEY", "CPA_MANAGEMENT_KEY_FILE", managementKeyFile) + if strings.TrimSpace(resolvedManagementKey) == "" { + resolvedManagementKey = strings.TrimSpace(cfgFile.CPAManagementKey) + } return Config{ HTTPAddr: env("HTTP_ADDR", stringFallback(cfgFile.HTTPAddr, "0.0.0.0:18317")), DBPath: env("USAGE_DB_PATH", dbPathFallback), CPAUpstreamURL: env("CPA_UPSTREAM_URL", cfgFile.CPAUpstreamURL), - ManagementKey: readSecret("CPA_MANAGEMENT_KEY", "CPA_MANAGEMENT_KEY_FILE", managementKeyFile), + ManagementKey: resolvedManagementKey, CollectorMode: normalizeCollectorMode(env("USAGE_COLLECTOR_MODE", stringFallback(cfgFile.CollectorMode, "auto"))), Queue: env("USAGE_RESP_QUEUE", stringFallback(cfgFile.Queue, "usage")), PopSide: env("USAGE_RESP_POP_SIDE", stringFallback(cfgFile.PopSide, "right")), diff --git a/usage-service/internal/httpapi/server.go b/usage-service/internal/httpapi/server.go index 240b0162b..97130efbf 100644 --- a/usage-service/internal/httpapi/server.go +++ b/usage-service/internal/httpapi/server.go @@ -1,10 +1,13 @@ package httpapi import ( + "crypto/sha256" + "encoding/hex" "context" "embed" "encoding/json" "errors" + "fmt" "io" "mime" "net/http" @@ -16,6 +19,7 @@ import ( "time" "github.com/seakee/cpa-manager/usage-service/internal/collector" + "github.com/seakee/cpa-manager/usage-service/internal/codexinspection" "github.com/seakee/cpa-manager/usage-service/internal/config" "github.com/seakee/cpa-manager/usage-service/internal/store" "github.com/seakee/cpa-manager/usage-service/internal/usage" @@ -28,6 +32,7 @@ type Server struct { cfg config.Config store *store.Store collector *collector.Manager + codex *codexinspection.Manager startedAt int64 } @@ -54,11 +59,12 @@ type modelPricesSyncRequest struct { Models []string `json:"models"` } -func New(cfg config.Config, store *store.Store, collector *collector.Manager) *Server { +func New(cfg config.Config, store *store.Store, collector *collector.Manager, codex *codexinspection.Manager) *Server { return &Server{ cfg: cfg, store: store, collector: collector, + codex: codex, startedAt: time.Now().UnixMilli(), } } @@ -84,6 +90,14 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { s.withCORS(s.handleModelPrices)(w, r) return } + if strings.HasPrefix(r.URL.Path, "/v0/management/api-key-map") { + s.withCORS(s.handleAPIKeyMap)(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/v0/management/codex-inspection") { + s.withCORS(s.handleCodexInspection)(w, r) + return + } if strings.HasPrefix(r.URL.Path, "/v0/management/usage") { s.withCORS(s.handleUsage)(w, r) return @@ -364,6 +378,107 @@ func selectModelPrices(prices map[string]store.ModelPrice, models []string) map[ return selected } +func (s *Server) handleAPIKeyMap(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + setup, ok, err := s.resolveSetup(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if !ok { + writeError(w, http.StatusPreconditionRequired, errors.New("usage service is not configured")) + return + } + if !authMatches(r, setup.ManagementKey) { + writeError(w, http.StatusUnauthorized, errors.New("invalid management key")) + return + } + items, err := fetchAPIKeyMap(r.Context(), setup.CPAUpstreamURL, setup.ManagementKey) + if err != nil { + writeError(w, http.StatusBadGateway, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +type apiKeyMapItem struct { + APIKeyHash string `json:"apiKeyHash"` + APIKeyLabel string `json:"apiKeyLabel"` + APIKeyMasked string `json:"apiKeyMasked"` +} + +func fetchAPIKeyMap(ctx context.Context, baseURL string, key string) ([]apiKeyMapItem, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v0/management/config", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+key) + client := &http.Client{Timeout: 15 * time.Second} + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, errors.New("api key map fetch failed: " + res.Status) + } + var payload map[string]any + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + return nil, err + } + raw := payload["api-keys"] + if raw == nil { + raw = payload["apiKeys"] + } + arr, ok := raw.([]any) + if !ok { + return []apiKeyMapItem{}, nil + } + items := make([]apiKeyMapItem, 0, len(arr)) + for _, item := range arr { + value := strings.TrimSpace(toString(item)) + if value == "" { + continue + } + masked := maskAPIKey(value) + items = append(items, apiKeyMapItem{ + APIKeyHash: sha256Hex(value), + APIKeyLabel: masked, + APIKeyMasked: masked, + }) + } + return items, nil +} + +func sha256Hex(value string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(value))) + return hex.EncodeToString(sum[:]) +} + +func maskAPIKey(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "-" + } + if len(trimmed) <= 4 { + return strings.Repeat("*", len(trimmed)) + } + if len(trimmed) <= 10 { + return trimmed[:2] + "***" + trimmed[len(trimmed)-1:] + } + return trimmed[:4] + "..." + trimmed[len(trimmed)-4:] +} + +func toString(value any) string { + if value == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(value)) +} + func findSuffixModelPrice(prices map[string]store.ModelPrice, model string) (store.ModelPrice, bool) { suffix := "/" + model var match store.ModelPrice @@ -683,3 +798,94 @@ func writeError(w http.ResponseWriter, status int, err error) { func methodNotAllowed(w http.ResponseWriter) { writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) } + + +type codexInspectionStartRequest struct { + TargetType string `json:"targetType"` + Workers int `json:"workers"` + DeleteWorkers int `json:"deleteWorkers"` + Timeout int `json:"timeout"` + Retries int `json:"retries"` + UserAgent string `json:"userAgent"` + UsedPercentThreshold float64 `json:"usedPercentThreshold"` + SampleSize int `json:"sampleSize"` + AutoExecuteActions bool `json:"autoExecuteActions"` + SelectedAccounts []string `json:"selectedAccounts"` +} + +type codexInspectionExecuteRequest struct { + RunID string `json:"runId"` + Keys []string `json:"keys"` + Action string `json:"action"` +} + +func (s *Server) handleCodexInspection(w http.ResponseWriter, r *http.Request) { + if !s.authorizeIfConfigured(w, r) { + return + } + if s.codex == nil { + writeError(w, http.StatusServiceUnavailable, errors.New("codex inspection service unavailable")) + return + } + path := strings.TrimRight(r.URL.Path, "/") + switch { + case path == "/v0/management/codex-inspection/runs" && r.Method == http.MethodPost: + var req codexInspectionStartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + run, err := s.codex.Start(r.Context(), codexinspection.Settings{ + TargetType: req.TargetType, + Workers: req.Workers, + DeleteWorkers: req.DeleteWorkers, + Timeout: req.Timeout, + Retries: req.Retries, + UserAgent: req.UserAgent, + UsedPercentThreshold: req.UsedPercentThreshold, + SampleSize: req.SampleSize, + AutoExecuteActions: req.AutoExecuteActions, + SelectedAccounts: codexinspection.NormalizeSelectedAccounts(req.SelectedAccounts), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"run": run}) + case path == "/v0/management/codex-inspection/runs" && r.Method == http.MethodGet: + runs, err := s.store.ListCodexInspectionRuns(r.Context(), 100) + if err != nil { writeError(w, http.StatusInternalServerError, err); return } + writeJSON(w, http.StatusOK, map[string]any{"runs": runs}) + case path == "/v0/management/codex-inspection/runs/latest" && r.Method == http.MethodGet: + run, ok, err := s.store.GetLatestCodexInspectionRun(r.Context()) + if err != nil { writeError(w, http.StatusInternalServerError, err); return } + if !ok { writeJSON(w, http.StatusOK, map[string]any{"run": nil}); return } + results, _ := s.store.LoadCodexInspectionResults(r.Context(), run.RunID) + logs, _ := s.store.LoadCodexInspectionLogs(r.Context(), run.RunID, 5000) + writeJSON(w, http.StatusOK, map[string]any{"run": run, "results": results, "logs": logs}) + case strings.HasPrefix(path, "/v0/management/codex-inspection/runs/") && strings.HasSuffix(path, "/pause") && r.Method == http.MethodPost: + runID := strings.TrimSuffix(strings.TrimPrefix(path, "/v0/management/codex-inspection/runs/"), "/pause") + s.codex.Stop(runID) + now := time.Now().UnixMilli() + run, ok, err := s.store.GetCodexInspectionRun(r.Context(), runID) + if err == nil && ok { run.Status = string(codexinspection.StatusPaused); run.UpdatedAtMS = now; _ = s.store.SaveCodexInspectionRun(r.Context(), run) } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case strings.HasPrefix(path, "/v0/management/codex-inspection/runs/") && strings.HasSuffix(path, "/stop") && r.Method == http.MethodPost: + runID := strings.TrimSuffix(strings.TrimPrefix(path, "/v0/management/codex-inspection/runs/"), "/stop") + s.codex.Stop(runID) + now := time.Now().UnixMilli() + run, ok, err := s.store.GetCodexInspectionRun(r.Context(), runID) + if err == nil && ok { run.Status = string(codexinspection.StatusStopped); run.UpdatedAtMS = now; run.FinishedAtMS = &now; _ = s.store.SaveCodexInspectionRun(r.Context(), run) } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case strings.HasPrefix(path, "/v0/management/codex-inspection/runs/") && r.Method == http.MethodGet: + runID := strings.TrimPrefix(path, "/v0/management/codex-inspection/runs/") + run, ok, err := s.store.GetCodexInspectionRun(r.Context(), runID) + if err != nil { writeError(w, http.StatusInternalServerError, err); return } + if !ok { writeError(w, http.StatusNotFound, errors.New("run not found")); return } + results, _ := s.store.LoadCodexInspectionResults(r.Context(), run.RunID) + logs, _ := s.store.LoadCodexInspectionLogs(r.Context(), run.RunID, 5000) + writeJSON(w, http.StatusOK, map[string]any{"run": run, "results": results, "logs": logs}) + default: + methodNotAllowed(w) + } +} diff --git a/usage-service/internal/store/store.go b/usage-service/internal/store/store.go index 0063deda7..fdf8b4858 100644 --- a/usage-service/internal/store/store.go +++ b/usage-service/internal/store/store.go @@ -44,6 +44,70 @@ type ModelPriceSyncResult struct { Skipped int `json:"skipped"` } +type CodexInspectionRun struct { + RunID string `json:"runId"` + RunName string `json:"runName"` + Status string `json:"status"` + TargetType string `json:"targetType"` + Workers int `json:"workers"` + DeleteWorkers int `json:"deleteWorkers"` + Timeout int `json:"timeout"` + Retries int `json:"retries"` + UserAgent string `json:"userAgent"` + UsedPercentThreshold float64 `json:"usedPercentThreshold"` + SampleSize int `json:"sampleSize"` + AutoExecuteActions bool `json:"autoExecuteActions"` + TargetScopeType string `json:"targetScopeType"` + SelectedTargetsJSON string `json:"selectedTargetsJson,omitempty"` + SummaryJSON string `json:"summaryJson,omitempty"` + ProgressJSON string `json:"progressJson,omitempty"` + StartedAtMS int64 `json:"startedAtMs"` + UpdatedAtMS int64 `json:"updatedAtMs"` + FinishedAtMS *int64 `json:"finishedAtMs,omitempty"` +} + +type CodexInspectionResultRow struct { + RunID string `json:"runId"` + AccountKey string `json:"accountKey"` + FileName string `json:"fileName"` + DisplayAccount string `json:"displayAccount"` + AuthIndex string `json:"authIndex,omitempty"` + AccountID string `json:"accountId,omitempty"` + Provider string `json:"provider,omitempty"` + Disabled bool `json:"disabled"` + Status string `json:"status,omitempty"` + State string `json:"state,omitempty"` + Action string `json:"action"` + ActionReason string `json:"actionReason,omitempty"` + StatusCode *int `json:"statusCode,omitempty"` + UsedPercent *float64 `json:"usedPercent,omitempty"` + IsQuota bool `json:"isQuota"` + Error string `json:"error,omitempty"` + ResponseBodyText string `json:"responseBodyText,omitempty"` + ResponseBodyJSON string `json:"responseBodyJson,omitempty"` + ResponseHeadersJSON string `json:"responseHeadersJson,omitempty"` + UpdatedAtMS int64 `json:"updatedAtMs"` +} + +type CodexInspectionLogRow struct { + ID int64 `json:"id"` + RunID string `json:"runId"` + Level string `json:"level"` + Message string `json:"message"` + CreatedAtMS int64 `json:"createdAtMs"` +} + +type CodexInspectionActionSelection struct { + RunID string `json:"runId"` + AccountKey string `json:"accountKey"` + Selected bool `json:"selected"` + PlannedAction string `json:"plannedAction"` + Executed bool `json:"executed"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + UpdatedAtMS int64 `json:"updatedAtMs"` +} + type Store struct { db *sql.DB } @@ -131,6 +195,72 @@ func (s *Store) init() error { updated_at_ms integer not null, synced_at_ms integer )`, + `create table if not exists codex_inspection_runs ( + run_id text primary key, + run_name text not null, + status text not null, + target_type text not null, + workers integer not null, + delete_workers integer not null, + timeout integer not null, + retries integer not null, + user_agent text not null, + used_percent_threshold real not null, + sample_size integer not null, + auto_execute_actions integer not null default 0, + target_scope_type text not null, + selected_targets_json text, + summary_json text, + progress_json text, + started_at_ms integer not null, + updated_at_ms integer not null, + finished_at_ms integer + )`, + `create index if not exists idx_codex_inspection_runs_started on codex_inspection_runs(started_at_ms desc)`, + `create table if not exists codex_inspection_results ( + run_id text not null, + account_key text not null, + file_name text not null, + display_account text not null, + auth_index text, + account_id text, + provider text, + disabled integer not null default 0, + status text, + state text, + action text not null, + action_reason text, + status_code integer, + used_percent real, + is_quota integer not null default 0, + error text, + response_body_text text, + response_body_json text, + response_headers_json text, + updated_at_ms integer not null, + primary key (run_id, account_key) + )`, + `create index if not exists idx_codex_inspection_results_run on codex_inspection_results(run_id)`, + `create table if not exists codex_inspection_logs ( + id integer primary key autoincrement, + run_id text not null, + level text not null, + message text not null, + created_at_ms integer not null + )`, + `create index if not exists idx_codex_inspection_logs_run on codex_inspection_logs(run_id, id)`, + `create table if not exists codex_inspection_action_selections ( + run_id text not null, + account_key text not null, + selected integer not null default 0, + planned_action text not null, + executed integer not null default 0, + success integer not null default 0, + error text, + updated_at_ms integer not null, + primary key (run_id, account_key) + )`, + `create index if not exists idx_codex_inspection_actions_run on codex_inspection_action_selections(run_id)`, } for _, statement := range statements { if _, err := s.db.Exec(statement); err != nil { @@ -544,3 +674,209 @@ func nullInt(value *int64) any { func (s Setup) String() string { return fmt.Sprintf("upstream=%s queue=%s popSide=%s", s.CPAUpstreamURL, s.Queue, s.PopSide) } + + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func nullIntValue(value *int) any { + if value == nil { + return nil + } + return *value +} + +func nullFloat64Value(value *float64) any { + if value == nil { + return nil + } + return *value +} + +func (s *Store) SaveCodexInspectionRun(ctx context.Context, run CodexInspectionRun) error { + _, err := s.db.ExecContext(ctx, `insert into codex_inspection_runs( + run_id, run_name, status, target_type, workers, delete_workers, timeout, retries, user_agent, + used_percent_threshold, sample_size, auto_execute_actions, target_scope_type, selected_targets_json, + summary_json, progress_json, started_at_ms, updated_at_ms, finished_at_ms + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(run_id) do update set + run_name=excluded.run_name, + status=excluded.status, + target_type=excluded.target_type, + workers=excluded.workers, + delete_workers=excluded.delete_workers, + timeout=excluded.timeout, + retries=excluded.retries, + user_agent=excluded.user_agent, + used_percent_threshold=excluded.used_percent_threshold, + sample_size=excluded.sample_size, + auto_execute_actions=excluded.auto_execute_actions, + target_scope_type=excluded.target_scope_type, + selected_targets_json=excluded.selected_targets_json, + summary_json=excluded.summary_json, + progress_json=excluded.progress_json, + started_at_ms=excluded.started_at_ms, + updated_at_ms=excluded.updated_at_ms, + finished_at_ms=excluded.finished_at_ms`, + run.RunID, run.RunName, run.Status, run.TargetType, run.Workers, run.DeleteWorkers, run.Timeout, run.Retries, run.UserAgent, + run.UsedPercentThreshold, run.SampleSize, boolToInt(run.AutoExecuteActions), run.TargetScopeType, nullString(run.SelectedTargetsJSON), + nullString(run.SummaryJSON), nullString(run.ProgressJSON), run.StartedAtMS, run.UpdatedAtMS, nullInt(run.FinishedAtMS)) + return err +} + +func (s *Store) SaveCodexInspectionResults(ctx context.Context, items []CodexInspectionResultRow) error { + if len(items) == 0 { return nil } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { return err } + defer func(){ _ = tx.Rollback() }() + stmt, err := tx.PrepareContext(ctx, `insert into codex_inspection_results( + run_id, account_key, file_name, display_account, auth_index, account_id, provider, disabled, status, state, + action, action_reason, status_code, used_percent, is_quota, error, response_body_text, response_body_json, + response_headers_json, updated_at_ms + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(run_id, account_key) do update set + file_name=excluded.file_name, + display_account=excluded.display_account, + auth_index=excluded.auth_index, + account_id=excluded.account_id, + provider=excluded.provider, + disabled=excluded.disabled, + status=excluded.status, + state=excluded.state, + action=excluded.action, + action_reason=excluded.action_reason, + status_code=excluded.status_code, + used_percent=excluded.used_percent, + is_quota=excluded.is_quota, + error=excluded.error, + response_body_text=excluded.response_body_text, + response_body_json=excluded.response_body_json, + response_headers_json=excluded.response_headers_json, + updated_at_ms=excluded.updated_at_ms`) + if err != nil { return err } + defer stmt.Close() + for _, item := range items { + _, err = stmt.ExecContext(ctx, + item.RunID, item.AccountKey, item.FileName, item.DisplayAccount, nullString(item.AuthIndex), nullString(item.AccountID), nullString(item.Provider), + boolToInt(item.Disabled), nullString(item.Status), nullString(item.State), item.Action, nullString(item.ActionReason), + nullIntValue(item.StatusCode), nullFloat64Value(item.UsedPercent), boolToInt(item.IsQuota), nullString(item.Error), + nullString(item.ResponseBodyText), nullString(item.ResponseBodyJSON), nullString(item.ResponseHeadersJSON), item.UpdatedAtMS) + if err != nil { return err } + } + return tx.Commit() +} + +func (s *Store) AddCodexInspectionLog(ctx context.Context, row CodexInspectionLogRow) error { + _, err := s.db.ExecContext(ctx, `insert into codex_inspection_logs(run_id, level, message, created_at_ms) values(?, ?, ?, ?)`, row.RunID, row.Level, row.Message, row.CreatedAtMS) + return err +} + +func (s *Store) ReplaceCodexInspectionActionSelections(ctx context.Context, runID string, items []CodexInspectionActionSelection) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { return err } + defer func(){ _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `delete from codex_inspection_action_selections where run_id = ?`, runID); err != nil { return err } + stmt, err := tx.PrepareContext(ctx, `insert into codex_inspection_action_selections(run_id, account_key, selected, planned_action, executed, success, error, updated_at_ms) values(?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { return err } + defer stmt.Close() + for _, item := range items { + if _, err := stmt.ExecContext(ctx, item.RunID, item.AccountKey, boolToInt(item.Selected), item.PlannedAction, boolToInt(item.Executed), boolToInt(item.Success), nullString(item.Error), item.UpdatedAtMS); err != nil { return err } + } + return tx.Commit() +} + + +func (s *Store) ListCodexInspectionRuns(ctx context.Context, limit int) ([]CodexInspectionRun, error) { + if limit <= 0 { limit = 50 } + rows, err := s.db.QueryContext(ctx, `select run_id, run_name, status, target_type, workers, delete_workers, timeout, retries, user_agent, + used_percent_threshold, sample_size, auto_execute_actions, target_scope_type, selected_targets_json, summary_json, progress_json, + started_at_ms, updated_at_ms, finished_at_ms from codex_inspection_runs order by started_at_ms desc limit ?`, limit) + if err != nil { return nil, err } + defer rows.Close() + out := make([]CodexInspectionRun, 0) + for rows.Next() { + var item CodexInspectionRun + var selected, summary, progress sql.NullString + var auto int + var finished sql.NullInt64 + if err := rows.Scan(&item.RunID, &item.RunName, &item.Status, &item.TargetType, &item.Workers, &item.DeleteWorkers, &item.Timeout, &item.Retries, &item.UserAgent, + &item.UsedPercentThreshold, &item.SampleSize, &auto, &item.TargetScopeType, &selected, &summary, &progress, &item.StartedAtMS, &item.UpdatedAtMS, &finished); err != nil { return nil, err } + item.AutoExecuteActions = auto != 0 + item.SelectedTargetsJSON = selected.String + item.SummaryJSON = summary.String + item.ProgressJSON = progress.String + if finished.Valid { v:=finished.Int64; item.FinishedAtMS=&v } + out = append(out, item) + } + return out, rows.Err() +} + +func (s *Store) GetCodexInspectionRun(ctx context.Context, runID string) (CodexInspectionRun, bool, error) { + var item CodexInspectionRun + var selected, summary, progress sql.NullString + var auto int + var finished sql.NullInt64 + err := s.db.QueryRowContext(ctx, `select run_id, run_name, status, target_type, workers, delete_workers, timeout, retries, user_agent, + used_percent_threshold, sample_size, auto_execute_actions, target_scope_type, selected_targets_json, summary_json, progress_json, + started_at_ms, updated_at_ms, finished_at_ms from codex_inspection_runs where run_id = ?`, runID).Scan( + &item.RunID, &item.RunName, &item.Status, &item.TargetType, &item.Workers, &item.DeleteWorkers, &item.Timeout, &item.Retries, &item.UserAgent, + &item.UsedPercentThreshold, &item.SampleSize, &auto, &item.TargetScopeType, &selected, &summary, &progress, &item.StartedAtMS, &item.UpdatedAtMS, &finished) + if errors.Is(err, sql.ErrNoRows) { return CodexInspectionRun{}, false, nil } + if err != nil { return CodexInspectionRun{}, false, err } + item.AutoExecuteActions = auto != 0 + item.SelectedTargetsJSON = selected.String + item.SummaryJSON = summary.String + item.ProgressJSON = progress.String + if finished.Valid { v:=finished.Int64; item.FinishedAtMS=&v } + return item, true, nil +} + +func (s *Store) GetLatestCodexInspectionRun(ctx context.Context) (CodexInspectionRun, bool, error) { + rows, err := s.ListCodexInspectionRuns(ctx, 1) + if err != nil || len(rows)==0 { + if err != nil { return CodexInspectionRun{}, false, err } + return CodexInspectionRun{}, false, nil + } + return rows[0], true, nil +} + +func (s *Store) LoadCodexInspectionResults(ctx context.Context, runID string) ([]CodexInspectionResultRow, error) { + rows, err := s.db.QueryContext(ctx, `select run_id, account_key, file_name, display_account, auth_index, account_id, provider, disabled, status, state, + action, action_reason, status_code, used_percent, is_quota, error, response_body_text, response_body_json, response_headers_json, updated_at_ms + from codex_inspection_results where run_id = ? order by file_name, display_account`, runID) + if err != nil { return nil, err } + defer rows.Close() + out := make([]CodexInspectionResultRow,0) + for rows.Next() { + var item CodexInspectionResultRow + var authIndex, accountID, provider, status, state, reason, errText, bodyText, bodyJSON, headersJSON sql.NullString + var disabled, isQuota int + var statusCode sql.NullInt64 + var usedPercent sql.NullFloat64 + if err := rows.Scan(&item.RunID, &item.AccountKey, &item.FileName, &item.DisplayAccount, &authIndex, &accountID, &provider, &disabled, &status, &state, + &item.Action, &reason, &statusCode, &usedPercent, &isQuota, &errText, &bodyText, &bodyJSON, &headersJSON, &item.UpdatedAtMS); err != nil { return nil, err } + item.AuthIndex = authIndex.String; item.AccountID = accountID.String; item.Provider = provider.String; item.Disabled = disabled != 0; item.Status = status.String; item.State = state.String; item.ActionReason = reason.String; item.IsQuota = isQuota != 0; item.Error = errText.String; item.ResponseBodyText = bodyText.String; item.ResponseBodyJSON = bodyJSON.String; item.ResponseHeadersJSON = headersJSON.String + if statusCode.Valid { v:=int(statusCode.Int64); item.StatusCode=&v } + if usedPercent.Valid { v:=usedPercent.Float64; item.UsedPercent=&v } + out = append(out, item) + } + return out, rows.Err() +} + +func (s *Store) LoadCodexInspectionLogs(ctx context.Context, runID string, limit int) ([]CodexInspectionLogRow, error) { + if limit <= 0 { limit = 2000 } + rows, err := s.db.QueryContext(ctx, `select id, run_id, level, message, created_at_ms from codex_inspection_logs where run_id = ? order by id asc limit ?`, runID, limit) + if err != nil { return nil, err } + defer rows.Close() + out := make([]CodexInspectionLogRow,0) + for rows.Next() { + var item CodexInspectionLogRow + if err := rows.Scan(&item.ID, &item.RunID, &item.Level, &item.Message, &item.CreatedAtMS); err != nil { return nil, err } + out = append(out, item) + } + return out, rows.Err() +} diff --git a/usage-service/internal/usage/event.go b/usage-service/internal/usage/event.go index 6e219fd9c..7c5ac543d 100644 --- a/usage-service/internal/usage/event.go +++ b/usage-service/internal/usage/event.go @@ -48,12 +48,15 @@ type Tokens struct { } type Detail struct { - Timestamp string `json:"timestamp"` - Source string `json:"source"` - AuthIndex string `json:"auth_index,omitempty"` - LatencyMS *int64 `json:"latency_ms,omitempty"` - Tokens Tokens `json:"tokens"` - Failed bool `json:"failed"` + Timestamp string `json:"timestamp"` + Source string `json:"source"` + Provider string `json:"provider,omitempty"` + AuthType string `json:"auth_type,omitempty"` + AuthIndex string `json:"auth_index,omitempty"` + APIKeyHash string `json:"api_key_hash,omitempty"` + LatencyMS *int64 `json:"latency_ms,omitempty"` + Tokens Tokens `json:"tokens"` + Failed bool `json:"failed"` } type ModelAggregate struct { @@ -182,11 +185,14 @@ func BuildPayload(events []Event) Payload { apiEntry.Models[model] = modelEntry } modelEntry.Details = append(modelEntry.Details, Detail{ - Timestamp: event.Timestamp, - Source: event.Source, - AuthIndex: event.AuthIndex, - LatencyMS: event.LatencyMS, - Failed: event.Failed, + Timestamp: event.Timestamp, + Source: event.Source, + Provider: event.Provider, + AuthType: event.AuthType, + AuthIndex: event.AuthIndex, + APIKeyHash: event.APIKeyHash, + LatencyMS: event.LatencyMS, + Failed: event.Failed, Tokens: Tokens{ InputTokens: event.InputTokens, OutputTokens: event.OutputTokens,