diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx index 9695c3d3..05d8d542 100644 --- a/src/components/config/VisualConfigEditor.tsx +++ b/src/components/config/VisualConfigEditor.tsx @@ -98,6 +98,10 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false, values.streaming.nonstreamKeepaliveInterval === '' || values.streaming.nonstreamKeepaliveInterval === '0'; const portError = getValidationMessage(t, validationErrors?.port); const logsMaxSizeError = getValidationMessage(t, validationErrors?.logsMaxTotalSizeMb); + const usagePersistenceIntervalError = getValidationMessage( + t, + validationErrors?.usagePersistenceIntervalSeconds + ); const requestRetryError = getValidationMessage(t, validationErrors?.requestRetry); const maxRetryIntervalError = getValidationMessage(t, validationErrors?.maxRetryInterval); const keepaliveError = getValidationMessage(t, validationErrors?.['streaming.keepaliveSeconds']); @@ -262,6 +266,13 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false, disabled={disabled} onChange={(usageStatisticsEnabled) => onChange({ usageStatisticsEnabled })} /> + onChange({ usagePersistenceEnabled })} + /> @@ -274,6 +285,22 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false, disabled={disabled} error={logsMaxSizeError} /> + onChange({ usagePersistenceFilePath: e.target.value })} + disabled={disabled} + /> + onChange({ usagePersistenceIntervalSeconds: e.target.value })} + disabled={disabled} + error={usagePersistenceIntervalError} + /> diff --git a/src/components/usage/hooks/useUsageData.ts b/src/components/usage/hooks/useUsageData.ts index b4e37a42..585da93f 100644 --- a/src/components/usage/hooks/useUsageData.ts +++ b/src/components/usage/hooks/useUsageData.ts @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { USAGE_STATS_STALE_TIME_MS, useNotificationStore, useUsageStatsStore } from '@/stores'; import { usageApi } from '@/services/api/usage'; +import type { UsagePersistenceConfig, UsagePersistenceStatus } from '@/services/api/usage'; import { downloadBlob } from '@/utils/download'; import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage'; @@ -28,6 +29,13 @@ export interface UseUsageDataReturn { importInputRef: React.RefObject; exporting: boolean; importing: boolean; + usagePersistenceConfig: UsagePersistenceConfig; + usagePersistenceStatus: UsagePersistenceStatus; + usagePersistenceLoading: boolean; + usagePersistenceError: string; + updateUsagePersistenceConfig: (config: UsagePersistenceConfig) => Promise; + saveUsageStatsNow: () => Promise; + loadUsageStatsNow: () => Promise; } export function useUsageData(): UseUsageDataReturn { @@ -38,6 +46,14 @@ export function useUsageData(): UseUsageDataReturn { const storeError = useUsageStatsStore((state) => state.error); const lastRefreshedAtTs = useUsageStatsStore((state) => state.lastRefreshedAt); const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats); + const usagePersistenceConfig = useUsageStatsStore((state) => state.usagePersistenceConfig); + const usagePersistenceStatus = useUsageStatsStore((state) => state.usagePersistenceStatus); + const usagePersistenceLoading = useUsageStatsStore((state) => state.usagePersistenceLoading); + const usagePersistenceError = useUsageStatsStore((state) => state.usagePersistenceError); + const loadUsagePersistenceState = useUsageStatsStore((state) => state.loadUsagePersistenceState); + const updateUsagePersistenceConfigInStore = useUsageStatsStore((state) => state.updateUsagePersistenceConfig); + const saveUsageStatsNowInStore = useUsageStatsStore((state) => state.saveUsageStatsNow); + const loadUsageStatsNowInStore = useUsageStatsStore((state) => state.loadUsageStatsNow); const [modelPrices, setModelPrices] = useState>({}); const [exporting, setExporting] = useState(false); @@ -46,12 +62,26 @@ export function useUsageData(): UseUsageDataReturn { const loadUsage = useCallback(async () => { await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS }); - }, [loadUsageStats]); + await saveUsageStatsNowInStore(); + }, [loadUsageStats, saveUsageStatsNowInStore]); useEffect(() => { void loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS }).catch(() => {}); + void loadUsagePersistenceState().catch(() => {}); setModelPrices(loadModelPrices()); - }, [loadUsageStats]); + }, [loadUsagePersistenceState, loadUsageStats]); + + const updateUsagePersistenceConfig = useCallback(async (config: UsagePersistenceConfig) => { + await updateUsagePersistenceConfigInStore(config); + }, [updateUsagePersistenceConfigInStore]); + + const saveUsageStatsNow = useCallback(async () => { + await saveUsageStatsNowInStore(); + }, [saveUsageStatsNowInStore]); + + const loadUsageStatsNow = useCallback(async () => { + await loadUsageStatsNowInStore(); + }, [loadUsageStatsNowInStore]); const handleExport = async () => { setExporting(true); @@ -151,6 +181,13 @@ export function useUsageData(): UseUsageDataReturn { handleImportChange, importInputRef, exporting, - importing + importing, + usagePersistenceConfig, + usagePersistenceStatus, + usagePersistenceLoading, + usagePersistenceError: usagePersistenceError || '', + updateUsagePersistenceConfig, + saveUsageStatsNow, + loadUsageStatsNow }; } diff --git a/src/hooks/useVisualConfig.ts b/src/hooks/useVisualConfig.ts index ff78777b..6da0adb4 100644 --- a/src/hooks/useVisualConfig.ts +++ b/src/hooks/useVisualConfig.ts @@ -219,6 +219,9 @@ export function getVisualConfigValidationErrors( return { port: getPortError(values.port), logsMaxTotalSizeMb: getNonNegativeIntegerError(values.logsMaxTotalSizeMb), + usagePersistenceIntervalSeconds: getNonNegativeIntegerError( + values.usagePersistenceIntervalSeconds + ), requestRetry: getNonNegativeIntegerError(values.requestRetry), maxRetryInterval: getNonNegativeIntegerError(values.maxRetryInterval), 'streaming.keepaliveSeconds': getNonNegativeIntegerError(values.streaming.keepaliveSeconds), @@ -468,6 +471,7 @@ export function useVisualConfig() { const tls = asRecord(parsed.tls); const remoteManagement = asRecord(parsed['remote-management']); const quotaExceeded = asRecord(parsed['quota-exceeded']); + const usagePersistence = asRecord(parsed['usage-persistence']); const routing = asRecord(parsed.routing); const payload = asRecord(parsed.payload); const streaming = asRecord(parsed.streaming); @@ -500,6 +504,10 @@ export function useVisualConfig() { loggingToFile: Boolean(parsed['logging-to-file']), logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''), usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']), + usagePersistenceEnabled: Boolean(usagePersistence?.enabled), + usagePersistenceFilePath: + typeof usagePersistence?.['file-path'] === 'string' ? usagePersistence['file-path'] : '', + usagePersistenceIntervalSeconds: String(usagePersistence?.['interval-seconds'] ?? '300'), proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '', forceModelPrefix: Boolean(parsed['force-model-prefix']), @@ -633,6 +641,23 @@ export function useVisualConfig() { setIntFromStringInDoc(doc, ['logs-max-total-size-mb'], values.logsMaxTotalSizeMb); setBooleanInDoc(doc, ['usage-statistics-enabled'], values.usageStatisticsEnabled); + if ( + docHas(doc, ['usage-persistence']) || + values.usagePersistenceEnabled || + values.usagePersistenceFilePath.trim() || + values.usagePersistenceIntervalSeconds.trim() + ) { + ensureMapInDoc(doc, ['usage-persistence']); + setBooleanInDoc(doc, ['usage-persistence', 'enabled'], values.usagePersistenceEnabled); + setStringInDoc(doc, ['usage-persistence', 'file-path'], values.usagePersistenceFilePath); + setIntFromStringInDoc( + doc, + ['usage-persistence', 'interval-seconds'], + values.usagePersistenceIntervalSeconds + ); + deleteIfMapEmpty(doc, ['usage-persistence']); + } + setStringInDoc(doc, ['proxy-url'], values.proxyUrl); setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix); setIntFromStringInDoc(doc, ['request-retry'], values.requestRetry); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c6cc4eb7..3f375aa6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -927,6 +927,16 @@ "export_success": "Usage export downloaded", "import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}", "import_invalid": "Invalid usage export file", + "persistence_title": "Usage Persistence", + "persistence_enabled": "Enable automatic persistence", + "persistence_file_path": "Persistence file path", + "persistence_interval_seconds": "Auto-save interval (seconds)", + "persistence_save_now": "Save now", + "persistence_load_now": "Load now", + "persistence_last_saved_at": "Last saved", + "persistence_last_loaded_at": "Last loaded", + "persistence_save_count": "Save count", + "persistence_load_count": "Load count", "chart_line_label_1": "Line 1", "chart_line_label_2": "Line 2", "chart_line_label_3": "Line 3", @@ -1176,6 +1186,10 @@ "logging_to_file_desc": "Save logs to files", "usage_statistics": "Usage Statistics", "usage_statistics_desc": "Collect usage statistics", + "usage_persistence_enabled": "Usage Persistence", + "usage_persistence_enabled_desc": "Persist usage statistics to file periodically", + "usage_persistence_file_path": "Usage Persistence File Path", + "usage_persistence_interval_seconds": "Usage Persistence Interval (seconds)", "logs_max_size": "Log File Size Limit (MB)" }, "network": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index bc7111e1..a4663b67 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -930,6 +930,16 @@ "export_success": "Экспорт использования скачан", "import_success": "Импорт завершён: добавлено {{added}}, пропущено {{skipped}}, всего {{total}}, ошибок {{failed}}", "import_invalid": "Недопустимый файл экспорта использования", + "persistence_title": "Сохранение статистики", + "persistence_enabled": "Включить автосохранение статистики", + "persistence_file_path": "Путь к файлу сохранения", + "persistence_interval_seconds": "Интервал автосохранения (сек)", + "persistence_save_now": "Сохранить сейчас", + "persistence_load_now": "Загрузить сейчас", + "persistence_last_saved_at": "Последнее сохранение", + "persistence_last_loaded_at": "Последняя загрузка", + "persistence_save_count": "Количество сохранений", + "persistence_load_count": "Количество загрузок", "chart_line_label_1": "Линия 1", "chart_line_label_2": "Линия 2", "chart_line_label_3": "Линия 3", @@ -1179,6 +1189,10 @@ "logging_to_file_desc": "Сохранять журналы в файлы", "usage_statistics": "Статистика использования", "usage_statistics_desc": "Собирать статистику использования", + "usage_persistence_enabled": "Персистентность статистики", + "usage_persistence_enabled_desc": "Периодически сохранять статистику использования в файл", + "usage_persistence_file_path": "Путь к файлу статистики", + "usage_persistence_interval_seconds": "Интервал сохранения статистики (сек)", "logs_max_size": "Максимальный размер файла журнала (МБ)", "usage_retention_days": "Хранить статистику (дней)", "usage_retention_hint": "0 означает без ограничений (без очистки)" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e01a95b8..ef3124a2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -927,6 +927,16 @@ "export_success": "使用统计已导出", "import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}", "import_invalid": "导入文件格式不正确", + "persistence_title": "统计持久化", + "persistence_enabled": "启用自动持久化", + "persistence_file_path": "持久化文件路径", + "persistence_interval_seconds": "自动保存间隔(秒)", + "persistence_save_now": "立即保存", + "persistence_load_now": "立即加载", + "persistence_last_saved_at": "最近保存", + "persistence_last_loaded_at": "最近加载", + "persistence_save_count": "保存次数", + "persistence_load_count": "加载次数", "chart_line_label_1": "曲线 1", "chart_line_label_2": "曲线 2", "chart_line_label_3": "曲线 3", @@ -1176,6 +1186,10 @@ "logging_to_file_desc": "将日志保存到文件", "usage_statistics": "使用统计", "usage_statistics_desc": "收集使用统计信息", + "usage_persistence_enabled": "统计持久化", + "usage_persistence_enabled_desc": "将使用统计周期性持久化到文件", + "usage_persistence_file_path": "统计持久化文件路径", + "usage_persistence_interval_seconds": "统计持久化间隔 (秒)", "logs_max_size": "日志文件大小限制 (MB)" }, "network": { diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index 80034c61..f6320cf4 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -128,6 +128,7 @@ export function UsagePage() { loading, error, lastRefreshedAt, + usagePersistenceStatus, modelPrices, setModelPrices, loadUsage, @@ -136,7 +137,8 @@ export function UsagePage() { handleImportChange, importInputRef, exporting, - importing + importing, + usagePersistenceError, } = useUsageData(); useHeaderRefresh(loadUsage); @@ -285,10 +287,17 @@ export function UsagePage() { {t('usage_stats.last_updated')}: {lastRefreshedAt.toLocaleTimeString()} )} + {usagePersistenceStatus.lastSavedAt && ( + + {t('usage_stats.persistence_last_saved_at')}:{' '} + {new Date(usagePersistenceStatus.lastSavedAt).toLocaleString()} + + )} {error &&
{error}
} + {usagePersistenceError &&
{usagePersistenceError}
} {/* Stats Overview Cards */} { return Boolean(value); }; +const normalizeNumber = (value: unknown): number | undefined => { + if (value === undefined || value === null) return undefined; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +}; + const normalizeModelAliases = (models: unknown): ModelAlias[] => { if (!Array.isArray(models)) return []; return models @@ -373,6 +383,21 @@ export const normalizeConfigResponse = (raw: unknown): Config => { config.usageStatisticsEnabled = normalizeBoolean( raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled ); + const usagePersistenceRaw = raw['usage-persistence'] ?? raw.usagePersistence; + if (isRecord(usagePersistenceRaw)) { + config.usagePersistence = { + enabled: normalizeBoolean(usagePersistenceRaw.enabled), + filePath: + typeof usagePersistenceRaw['file-path'] === 'string' + ? usagePersistenceRaw['file-path'] + : typeof usagePersistenceRaw.filePath === 'string' + ? usagePersistenceRaw.filePath + : undefined, + intervalSeconds: normalizeNumber( + usagePersistenceRaw['interval-seconds'] ?? usagePersistenceRaw.intervalSeconds + ) + }; + } config.requestLog = normalizeBoolean(raw['request-log'] ?? raw.requestLog); config.loggingToFile = normalizeBoolean(raw['logging-to-file'] ?? raw.loggingToFile); const logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb; diff --git a/src/services/api/usage.ts b/src/services/api/usage.ts index 6041c237..bc609c89 100644 --- a/src/services/api/usage.ts +++ b/src/services/api/usage.ts @@ -22,6 +22,165 @@ export interface UsageImportResponse { [key: string]: unknown; } +export interface UsagePersistenceConfig { + enabled?: boolean; + filePath?: string; + intervalSeconds?: number; + maxDetailsPerModel?: number; +} + +export interface UsagePersistenceStatus { + enabled?: boolean; + filePath?: string; + intervalSeconds?: number; + maxDetailsPerModel?: number; + lastLoadedAt?: string; + lastSavedAt?: string; + saveCount?: number; + loadCount?: number; + lastLoadAdded?: number; + lastLoadSkipped?: number; + lastError?: string; + [key: string]: unknown; +} + +export interface UsagePersistenceLoadResult { + loaded?: boolean; + filePath?: string; + added?: number; + skipped?: number; + [key: string]: unknown; +} + +type UsagePersistenceConfigResponse = { + 'usage-persistence'?: Record; + status?: { + runtime?: Record; + }; +}; + +const parseUsagePersistenceConfig = (payload: unknown): UsagePersistenceConfig => { + if (!payload || typeof payload !== 'object') return {}; + const record = payload as Record; + return { + enabled: typeof record.enabled === 'boolean' ? record.enabled : undefined, + filePath: + typeof record['file-path'] === 'string' + ? record['file-path'] + : typeof record.filePath === 'string' + ? record.filePath + : undefined, + intervalSeconds: + typeof record['interval-seconds'] === 'number' + ? record['interval-seconds'] + : typeof record.intervalSeconds === 'number' + ? record.intervalSeconds + : undefined, + maxDetailsPerModel: + typeof record['max-details-per-model'] === 'number' + ? record['max-details-per-model'] + : typeof record.maxDetailsPerModel === 'number' + ? record.maxDetailsPerModel + : undefined + }; +}; + +const parseUsagePersistenceStatus = (payload: unknown): UsagePersistenceStatus => { + if (!payload || typeof payload !== 'object') return {}; + const record = payload as Record; + return { + enabled: typeof record.enabled === 'boolean' ? record.enabled : undefined, + filePath: + typeof record['file-path'] === 'string' + ? record['file-path'] + : typeof record.path === 'string' + ? record.path + : typeof record.filePath === 'string' + ? record.filePath + : undefined, + intervalSeconds: + typeof record['interval-seconds'] === 'number' + ? record['interval-seconds'] + : typeof record.interval_seconds === 'number' + ? record.interval_seconds + : typeof record.intervalSeconds === 'number' + ? record.intervalSeconds + : undefined, + maxDetailsPerModel: + typeof record['max-details-per-model'] === 'number' + ? record['max-details-per-model'] + : typeof record.max_details_per_model === 'number' + ? record.max_details_per_model + : typeof record.maxDetailsPerModel === 'number' + ? record.maxDetailsPerModel + : undefined, + lastLoadedAt: + typeof record['last-loaded-at'] === 'string' + ? record['last-loaded-at'] + : typeof record.last_loaded_at === 'string' + ? record.last_loaded_at + : typeof record.lastLoadedAt === 'string' + ? record.lastLoadedAt + : undefined, + lastSavedAt: + typeof record['last-saved-at'] === 'string' + ? record['last-saved-at'] + : typeof record.last_saved_at === 'string' + ? record.last_saved_at + : typeof record.lastSavedAt === 'string' + ? record.lastSavedAt + : undefined, + saveCount: + typeof record['save-count'] === 'number' + ? record['save-count'] + : typeof record.saveCount === 'number' + ? record.saveCount + : undefined, + loadCount: + typeof record['load-count'] === 'number' + ? record['load-count'] + : typeof record.loadCount === 'number' + ? record.loadCount + : undefined, + lastLoadAdded: + typeof record['last-load-added'] === 'number' + ? record['last-load-added'] + : typeof record.lastLoadAdded === 'number' + ? record.lastLoadAdded + : undefined, + lastLoadSkipped: + typeof record['last-load-skipped'] === 'number' + ? record['last-load-skipped'] + : typeof record.lastLoadSkipped === 'number' + ? record.lastLoadSkipped + : undefined, + lastError: + typeof record['last-error'] === 'string' + ? record['last-error'] + : typeof record.last_error === 'string' + ? record.last_error + : typeof record.lastError === 'string' + ? record.lastError + : undefined + }; +}; + +const parseUsagePersistenceLoadResult = (payload: unknown): UsagePersistenceLoadResult => { + if (!payload || typeof payload !== 'object') return {}; + const record = payload as Record; + return { + loaded: typeof record.loaded === 'boolean' ? record.loaded : undefined, + filePath: + typeof record['file-path'] === 'string' + ? record['file-path'] + : typeof record.filePath === 'string' + ? record.filePath + : undefined, + added: typeof record.added === 'number' ? record.added : undefined, + skipped: typeof record.skipped === 'number' ? record.skipped : undefined + }; +}; + export const usageApi = { /** * 获取使用统计原始数据 @@ -39,6 +198,82 @@ export const usageApi = { importUsage: (payload: unknown) => apiClient.post('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }), + /** + * 获取 usage 持久化配置 + */ + async getUsagePersistenceConfig(): Promise<{ + config: UsagePersistenceConfig; + status: UsagePersistenceStatus; + }> { + const response = await apiClient.get('/usage-persistence', { + timeout: USAGE_TIMEOUT_MS + }); + return { + config: parseUsagePersistenceConfig(response?.['usage-persistence']), + status: parseUsagePersistenceStatus(response?.status?.runtime) + }; + }, + + /** + * 更新 usage 持久化配置 + */ + async updateUsagePersistenceConfig(config: UsagePersistenceConfig): Promise { + const payload: Record = {}; + if (typeof config.enabled === 'boolean') payload.enabled = config.enabled; + if (typeof config.filePath === 'string') payload['file-path'] = config.filePath; + if (typeof config.intervalSeconds === 'number') { + payload['interval-seconds'] = config.intervalSeconds; + } + if (typeof config.maxDetailsPerModel === 'number') { + payload['max-details-per-model'] = config.maxDetailsPerModel; + } + const response = await apiClient.put('/usage-persistence', payload, { + timeout: USAGE_TIMEOUT_MS + }); + return parseUsagePersistenceConfig(response?.['usage-persistence']); + }, + + /** + * 获取 usage 持久化运行时状态 + */ + async getUsagePersistenceStatus(): Promise { + const response = await apiClient.get<{ status?: Record }>( + '/usage/persistence-status', + { timeout: USAGE_TIMEOUT_MS } + ); + return parseUsagePersistenceStatus(response?.status); + }, + + /** + * 立即保存 usage 统计 + */ + async saveUsageStatistics(): Promise { + const response = await apiClient.post<{ status?: Record }>( + '/usage/save', + {}, + { timeout: USAGE_TIMEOUT_MS } + ); + return parseUsagePersistenceStatus(response?.status); + }, + + /** + * 立即加载 usage 统计 + */ + async loadUsageStatistics(): Promise<{ + result: UsagePersistenceLoadResult; + totalRequests?: number; + failedRequests?: number; + }> { + const response = await apiClient.post>('/usage/load', {}, { timeout: USAGE_TIMEOUT_MS }); + return { + result: parseUsagePersistenceLoadResult(response?.result), + totalRequests: + typeof response?.total_requests === 'number' ? response.total_requests : undefined, + failedRequests: + typeof response?.failed_requests === 'number' ? response.failed_requests : undefined + }; + }, + /** * 计算密钥成功/失败统计,必要时会先获取 usage 数据 */ diff --git a/src/stores/useConfigStore.ts b/src/stores/useConfigStore.ts index da8f2a05..f1d5855a 100644 --- a/src/stores/useConfigStore.ts +++ b/src/stores/useConfigStore.ts @@ -39,6 +39,7 @@ const SECTION_KEYS: RawConfigSection[] = [ 'request-retry', 'quota-exceeded', 'usage-statistics-enabled', + 'usage-persistence', 'request-log', 'logging-to-file', 'logs-max-total-size-mb', @@ -68,6 +69,8 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection) return config.quotaExceeded; case 'usage-statistics-enabled': return config.usageStatisticsEnabled; + case 'usage-persistence': + return config.usagePersistence; case 'request-log': return config.requestLog; case 'logging-to-file': @@ -205,6 +208,9 @@ export const useConfigStore = create((set, get) => ({ case 'usage-statistics-enabled': nextConfig.usageStatisticsEnabled = value as Config['usageStatisticsEnabled']; break; + case 'usage-persistence': + nextConfig.usagePersistence = value as Config['usagePersistence']; + break; case 'request-log': nextConfig.requestLog = value as Config['requestLog']; break; diff --git a/src/stores/useUsageStatsStore.ts b/src/stores/useUsageStatsStore.ts index e431f586..99e2a18f 100644 --- a/src/stores/useUsageStatsStore.ts +++ b/src/stores/useUsageStatsStore.ts @@ -3,6 +3,10 @@ import { usageApi } from '@/services/api'; import { useAuthStore } from '@/stores/useAuthStore'; import { collectUsageDetails, computeKeyStatsFromDetails, type KeyStats, type UsageDetail } from '@/utils/usage'; import i18n from '@/i18n'; +import type { + UsagePersistenceConfig, + UsagePersistenceStatus, +} from '@/services/api/usage'; export const USAGE_STATS_STALE_TIME_MS = 240_000; @@ -20,8 +24,16 @@ type UsageStatsState = { loading: boolean; error: string | null; lastRefreshedAt: number | null; + usagePersistenceConfig: UsagePersistenceConfig; + usagePersistenceStatus: UsagePersistenceStatus; + usagePersistenceLoading: boolean; + usagePersistenceError: string | null; scopeKey: string; loadUsageStats: (options?: LoadUsageStatsOptions) => Promise; + loadUsagePersistenceState: () => Promise; + updateUsagePersistenceConfig: (config: UsagePersistenceConfig) => Promise; + saveUsageStatsNow: () => Promise; + loadUsageStatsNow: () => Promise; clearUsageStats: () => void; }; @@ -44,6 +56,10 @@ export const useUsageStatsStore = create((set, get) => ({ loading: false, error: null, lastRefreshedAt: null, + usagePersistenceConfig: {}, + usagePersistenceStatus: {}, + usagePersistenceLoading: false, + usagePersistenceError: null, scopeKey: '', loadUsageStats: async (options = {}) => { @@ -82,6 +98,10 @@ export const useUsageStatsStore = create((set, get) => ({ usageDetails: [], error: null, lastRefreshedAt: null, + usagePersistenceConfig: {}, + usagePersistenceStatus: {}, + usagePersistenceLoading: false, + usagePersistenceError: null, scopeKey }); } @@ -128,6 +148,89 @@ export const useUsageStatsStore = create((set, get) => ({ await requestPromise; }, + loadUsagePersistenceState: async () => { + set({ usagePersistenceLoading: true, usagePersistenceError: null }); + try { + const [configRes, statusRes] = await Promise.all([ + usageApi.getUsagePersistenceConfig(), + usageApi.getUsagePersistenceStatus(), + ]); + set({ + usagePersistenceConfig: configRes.config, + usagePersistenceStatus: statusRes, + usagePersistenceLoading: false, + usagePersistenceError: null, + }); + } catch (error: unknown) { + set({ + usagePersistenceLoading: false, + usagePersistenceError: getErrorMessage(error), + }); + throw error; + } + }, + + updateUsagePersistenceConfig: async (config) => { + set({ usagePersistenceLoading: true, usagePersistenceError: null }); + try { + await usageApi.updateUsagePersistenceConfig(config); + const [configRes, statusRes] = await Promise.all([ + usageApi.getUsagePersistenceConfig(), + usageApi.getUsagePersistenceStatus(), + ]); + set({ + usagePersistenceConfig: configRes.config, + usagePersistenceStatus: statusRes, + usagePersistenceLoading: false, + usagePersistenceError: null, + }); + } catch (error: unknown) { + set({ + usagePersistenceLoading: false, + usagePersistenceError: getErrorMessage(error), + }); + throw error; + } + }, + + saveUsageStatsNow: async () => { + set({ usagePersistenceLoading: true, usagePersistenceError: null }); + try { + const status = await usageApi.saveUsageStatistics(); + set({ + usagePersistenceStatus: status, + usagePersistenceLoading: false, + usagePersistenceError: null, + }); + } catch (error: unknown) { + set({ + usagePersistenceLoading: false, + usagePersistenceError: getErrorMessage(error), + }); + throw error; + } + }, + + loadUsageStatsNow: async () => { + set({ usagePersistenceLoading: true, usagePersistenceError: null }); + try { + await usageApi.loadUsageStatistics(); + await get().loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS }); + const status = await usageApi.getUsagePersistenceStatus(); + set({ + usagePersistenceStatus: status, + usagePersistenceLoading: false, + usagePersistenceError: null, + }); + } catch (error: unknown) { + set({ + usagePersistenceLoading: false, + usagePersistenceError: getErrorMessage(error), + }); + throw error; + } + }, + clearUsageStats: () => { usageRequestToken += 1; inFlightUsageRequest = null; @@ -138,6 +241,10 @@ export const useUsageStatsStore = create((set, get) => ({ loading: false, error: null, lastRefreshedAt: null, + usagePersistenceConfig: {}, + usagePersistenceStatus: {}, + usagePersistenceLoading: false, + usagePersistenceError: null, scopeKey: '' }); } diff --git a/src/types/config.ts b/src/types/config.ts index a248efbd..02ca588d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -11,12 +11,20 @@ export interface QuotaExceededConfig { switchPreviewModel?: boolean; } +export interface UsagePersistenceConfig { + enabled?: boolean; + filePath?: string; + intervalSeconds?: number; + maxDetailsPerModel?: number; +} + export interface Config { debug?: boolean; proxyUrl?: string; requestRetry?: number; quotaExceeded?: QuotaExceededConfig; usageStatisticsEnabled?: boolean; + usagePersistence?: UsagePersistenceConfig; requestLog?: boolean; loggingToFile?: boolean; logsMaxTotalSizeMb?: number; @@ -40,6 +48,7 @@ export type RawConfigSection = | 'request-retry' | 'quota-exceeded' | 'usage-statistics-enabled' + | 'usage-persistence' | 'request-log' | 'logging-to-file' | 'logs-max-total-size-mb' diff --git a/src/types/visualConfig.ts b/src/types/visualConfig.ts index 7990d288..9bdcef5e 100644 --- a/src/types/visualConfig.ts +++ b/src/types/visualConfig.ts @@ -7,6 +7,7 @@ export type PayloadParamValidationErrorCode = export type VisualConfigFieldPath = | 'port' | 'logsMaxTotalSizeMb' + | 'usagePersistenceIntervalSeconds' | 'requestRetry' | 'maxRetryInterval' | 'streaming.keepaliveSeconds' @@ -67,6 +68,9 @@ export type VisualConfigValues = { loggingToFile: boolean; logsMaxTotalSizeMb: string; usageStatisticsEnabled: boolean; + usagePersistenceEnabled: boolean; + usagePersistenceFilePath: string; + usagePersistenceIntervalSeconds: string; proxyUrl: string; forceModelPrefix: boolean; requestRetry: string; @@ -103,6 +107,9 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = { loggingToFile: false, logsMaxTotalSizeMb: '', usageStatisticsEnabled: false, + usagePersistenceEnabled: false, + usagePersistenceFilePath: '', + usagePersistenceIntervalSeconds: '', proxyUrl: '', forceModelPrefix: false, requestRetry: '', diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 07abb00f..ef125e97 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -444,6 +444,59 @@ export function formatUsd(value: number): string { const usageDetailsCache = new WeakMap(); const usageDetailsWithEndpointCache = new WeakMap(); +const getModelBucketRecord = ( + modelEntry: Record, + metric: 'requests' | 'tokens', + granularity: 'hour' | 'day' +): Record | null => { + const snakeCaseKey = `${metric}_by_${granularity}`; + const camelCaseKey = + metric === 'requests' + ? granularity === 'hour' + ? 'requestsByHour' + : 'requestsByDay' + : granularity === 'hour' + ? 'tokensByHour' + : 'tokensByDay'; + const value = modelEntry[snakeCaseKey] ?? modelEntry[camelCaseKey]; + return isRecord(value) ? value : null; +}; + +const collectModelMetricBuckets = ( + usageData: unknown, + metric: 'requests' | 'tokens', + granularity: 'hour' | 'day' +): Map> => { + const apis = getApisRecord(usageData); + const totalsByModel = new Map>(); + if (!apis) return totalsByModel; + + Object.values(apis).forEach((apiEntry) => { + if (!isRecord(apiEntry)) return; + const models = isRecord(apiEntry.models) ? apiEntry.models : null; + if (!models) return; + + Object.entries(models).forEach(([modelName, modelEntry]) => { + if (!isRecord(modelEntry)) return; + const bucketRecord = getModelBucketRecord(modelEntry, metric, granularity); + if (!bucketRecord) return; + + if (!totalsByModel.has(modelName)) { + totalsByModel.set(modelName, new Map()); + } + + const bucketMap = totalsByModel.get(modelName)!; + Object.entries(bucketRecord).forEach(([bucketKey, bucketValue]) => { + const value = Number(bucketValue); + if (!Number.isFinite(value)) return; + bucketMap.set(bucketKey, (bucketMap.get(bucketKey) || 0) + value); + }); + }); + }); + + return totalsByModel; +}; + /** * 从使用数据中收集所有请求明细 */ @@ -1002,6 +1055,35 @@ export function buildHourlySeriesByModel( labels.push(formatHourLabel(new Date(bucketStart))); } + const aggregatedByModel = collectModelMetricBuckets(usageData, metric, 'hour'); + if (aggregatedByModel.size > 0) { + const dataByModel = new Map(); + let hasData = false; + const lastBucketTime = earliestTime + (labels.length - 1) * hourMs; + + aggregatedByModel.forEach((bucketMap, modelName) => { + const values = new Array(labels.length).fill(0); + bucketMap.forEach((bucketValue, bucketKey) => { + const parsed = Date.parse(bucketKey); + if (!Number.isFinite(parsed)) return; + + const normalized = new Date(parsed); + normalized.setMinutes(0, 0, 0); + const bucketStart = normalized.getTime(); + if (bucketStart < earliestTime || bucketStart > lastBucketTime) return; + + const index = Math.floor((bucketStart - earliestTime) / hourMs); + if (index < 0 || index >= labels.length) return; + + values[index] += bucketValue; + hasData = true; + }); + dataByModel.set(modelName, values); + }); + + return { labels, dataByModel, hasData }; + } + const details = collectUsageDetails(usageData); const dataByModel = new Map(); let hasData = false; @@ -1058,6 +1140,31 @@ export function buildDailySeriesByModel( dataByModel: Map; hasData: boolean; } { + const aggregatedByModel = collectModelMetricBuckets(usageData, metric, 'day'); + if (aggregatedByModel.size > 0) { + const labelsSet = new Set(); + const dataByModel = new Map(); + let hasData = false; + + aggregatedByModel.forEach((bucketMap) => { + bucketMap.forEach((_value, bucketKey) => { + if (bucketKey) labelsSet.add(bucketKey); + }); + }); + + const labels = Array.from(labelsSet).sort(); + aggregatedByModel.forEach((bucketMap, modelName) => { + const values = labels.map((label) => { + const value = bucketMap.get(label) || 0; + if (value !== 0) hasData = true; + return value; + }); + dataByModel.set(modelName, values); + }); + + return { labels, dataByModel, hasData }; + } + const details = collectUsageDetails(usageData); const valuesByModel = new Map>(); const labelsSet = new Set();