Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/components/config/VisualConfigEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -262,6 +266,13 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false,
disabled={disabled}
onChange={(usageStatisticsEnabled) => onChange({ usageStatisticsEnabled })}
/>
<ToggleRow
title={t('config_management.visual.sections.system.usage_persistence_enabled')}
description={t('config_management.visual.sections.system.usage_persistence_enabled_desc')}
checked={values.usagePersistenceEnabled}
disabled={disabled}
onChange={(usagePersistenceEnabled) => onChange({ usagePersistenceEnabled })}
/>
</SectionGrid>

<SectionGrid>
Expand All @@ -274,6 +285,22 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false,
disabled={disabled}
error={logsMaxSizeError}
/>
<Input
label={t('config_management.visual.sections.system.usage_persistence_file_path')}
placeholder="usage-statistics.json"
value={values.usagePersistenceFilePath}
onChange={(e) => onChange({ usagePersistenceFilePath: e.target.value })}
disabled={disabled}
/>
<Input
label={t('config_management.visual.sections.system.usage_persistence_interval_seconds')}
type="number"
placeholder="300"
value={values.usagePersistenceIntervalSeconds}
onChange={(e) => onChange({ usagePersistenceIntervalSeconds: e.target.value })}
disabled={disabled}
error={usagePersistenceIntervalError}
/>
</SectionGrid>
</div>
</ConfigSection>
Expand Down
43 changes: 40 additions & 3 deletions src/components/usage/hooks/useUsageData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -28,6 +29,13 @@ export interface UseUsageDataReturn {
importInputRef: React.RefObject<HTMLInputElement | null>;
exporting: boolean;
importing: boolean;
usagePersistenceConfig: UsagePersistenceConfig;
usagePersistenceStatus: UsagePersistenceStatus;
usagePersistenceLoading: boolean;
usagePersistenceError: string;
updateUsagePersistenceConfig: (config: UsagePersistenceConfig) => Promise<void>;
saveUsageStatsNow: () => Promise<void>;
loadUsageStatsNow: () => Promise<void>;
}

export function useUsageData(): UseUsageDataReturn {
Expand All @@ -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<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
Expand All @@ -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);
Expand Down Expand Up @@ -151,6 +181,13 @@ export function useUsageData(): UseUsageDataReturn {
handleImportChange,
importInputRef,
exporting,
importing
importing,
usagePersistenceConfig,
usagePersistenceStatus,
usagePersistenceLoading,
usagePersistenceError: usagePersistenceError || '',
updateUsagePersistenceConfig,
saveUsageStatsNow,
loadUsageStatsNow
};
}
25 changes: 25 additions & 0 deletions src/hooks/useVisualConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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']),
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
14 changes: 14 additions & 0 deletions src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 означает без ограничений (без очистки)"
Expand Down
14 changes: 14 additions & 0 deletions src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
11 changes: 10 additions & 1 deletion src/pages/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function UsagePage() {
loading,
error,
lastRefreshedAt,
usagePersistenceStatus,
modelPrices,
setModelPrices,
loadUsage,
Expand All @@ -136,7 +137,8 @@ export function UsagePage() {
handleImportChange,
importInputRef,
exporting,
importing
importing,
usagePersistenceError,
} = useUsageData();

useHeaderRefresh(loadUsage);
Expand Down Expand Up @@ -285,10 +287,17 @@ export function UsagePage() {
{t('usage_stats.last_updated')}: {lastRefreshedAt.toLocaleTimeString()}
</span>
)}
{usagePersistenceStatus.lastSavedAt && (
<span className={styles.lastRefreshed}>
{t('usage_stats.persistence_last_saved_at')}:{' '}
{new Date(usagePersistenceStatus.lastSavedAt).toLocaleString()}
</span>
)}
</div>
</div>

{error && <div className={styles.errorBox}>{error}</div>}
{usagePersistenceError && <div className={styles.errorBox}>{usagePersistenceError}</div>}

{/* Stats Overview Cards */}
<StatCards
Expand Down
25 changes: 25 additions & 0 deletions src/services/api/transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ const normalizeBoolean = (value: unknown): boolean | undefined => {
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
Expand Down Expand Up @@ -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;
Expand Down
Loading