feat(usage): add manual historical cache data repair
This commit is contained in:
1 parent
f695193e24
commit
44dcdf13a8
7 files changed
+263
-40
No files matched your search
@@ -113,6 +113,7 @@ export const jaOverrides = {
|
||||
'usage.tab.analysis': '分析',
|
||||
'usage.tab.events': 'リクエスト詳細',
|
||||
'usage.tab.pricing': '料金統計',
|
||||
'usage.tab.dataManagement': 'データ管理',
|
||||
'usage.refresh': '更新',
|
||||
'usage.filter.reset': 'フィルター解除',
|
||||
'usage.filter.timeRange': '期間',
|
||||
@@ -179,6 +180,18 @@ export const jaOverrides = {
|
||||
'usage.pricing.saved': 'モデル料金を保存し、概要のコストを更新しました',
|
||||
'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました',
|
||||
'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?',
|
||||
'usage.dataManagement.title': '履歴データ管理',
|
||||
'usage.dataManagement.description': 'キャッシュ Token の不整合がある使用履歴をスキャンして修復します。',
|
||||
'usage.dataManagement.manualBadge': '手動操作',
|
||||
'usage.dataManagement.notice': '修復前にデータベースをバックアップします。判定可能な Claude / Anthropic の記録のみ処理します。',
|
||||
'usage.dataManagement.actionTitle': 'キャッシュ記録を修復',
|
||||
'usage.dataManagement.actionDescription': 'キャッシュ読み取りと作成を含む入力 Token の集計方式に統一します。',
|
||||
'usage.dataManagement.run': 'スキャンして修復',
|
||||
'usage.dataManagement.running': '処理中…',
|
||||
'usage.dataManagement.confirm': '履歴キャッシュ記録をスキャンして修復しますか?先にデータベースをバックアップします。',
|
||||
'usage.dataManagement.scanned': 'スキャン件数',
|
||||
'usage.dataManagement.repaired': '修復件数',
|
||||
'usage.dataManagement.backup': 'バックアップ',
|
||||
'usage.trend.title': 'リクエストと Token の推移',
|
||||
'usage.trend.description': 'システムのローカル時刻ごとに集計',
|
||||
'usage.token.title': 'Token の内訳',
|
||||
|
||||
@@ -113,6 +113,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.tab.analysis': 'Analysis',
|
||||
'usage.tab.events': 'Request Details',
|
||||
'usage.tab.pricing': 'Price Statistics',
|
||||
'usage.tab.dataManagement': 'Data Management',
|
||||
'usage.refresh': 'Refresh',
|
||||
'usage.filter.reset': 'Reset Filters',
|
||||
'usage.filter.timeRange': 'Time range',
|
||||
@@ -179,6 +180,18 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.pricing.saved': 'Model price saved; overview cost updated',
|
||||
'usage.pricing.deleted': 'Model price deleted; overview cost updated',
|
||||
'usage.pricing.deleteConfirm': 'Delete the price for {model}?',
|
||||
'usage.dataManagement.title': 'Historical Data Management',
|
||||
'usage.dataManagement.description': 'Scan and repair historical usage records with inconsistent cache token accounting.',
|
||||
'usage.dataManagement.manualBadge': 'Manual action',
|
||||
'usage.dataManagement.notice': 'A database backup is created first. Only identifiable Claude / Anthropic records are processed.',
|
||||
'usage.dataManagement.actionTitle': 'Repair cache records',
|
||||
'usage.dataManagement.actionDescription': 'Use input token accounting that includes cache reads and cache creation.',
|
||||
'usage.dataManagement.run': 'Scan and repair',
|
||||
'usage.dataManagement.running': 'Processing…',
|
||||
'usage.dataManagement.confirm': 'Scan and repair historical cache records? A database backup will be created first.',
|
||||
'usage.dataManagement.scanned': 'Scanned',
|
||||
'usage.dataManagement.repaired': 'Repaired',
|
||||
'usage.dataManagement.backup': 'Backup file',
|
||||
'usage.trend.title': 'Request and Token Trend',
|
||||
'usage.trend.description': 'Grouped by local system hour',
|
||||
'usage.token.title': 'Token Breakdown',
|
||||
|
||||
@@ -111,6 +111,7 @@ export const zhCN = {
|
||||
'usage.tab.analysis': '分析',
|
||||
'usage.tab.events': '请求明细',
|
||||
'usage.tab.pricing': '价格统计',
|
||||
'usage.tab.dataManagement': '数据管理',
|
||||
'usage.refresh': '刷新数据',
|
||||
'usage.filter.reset': '重置筛选',
|
||||
'usage.filter.timeRange': '时间范围',
|
||||
@@ -177,6 +178,18 @@ export const zhCN = {
|
||||
'usage.pricing.saved': '模型价格已保存,总览成本已更新',
|
||||
'usage.pricing.deleted': '模型价格已删除,总览成本已更新',
|
||||
'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?',
|
||||
'usage.dataManagement.title': '历史数据管理',
|
||||
'usage.dataManagement.description': '扫描并修复缓存 Token 口径异常的历史使用记录。',
|
||||
'usage.dataManagement.manualBadge': '手动操作',
|
||||
'usage.dataManagement.notice': '修复前会自动备份数据库,仅处理可确认的 Claude / Anthropic 异常记录。',
|
||||
'usage.dataManagement.actionTitle': '修复异常缓存记录',
|
||||
'usage.dataManagement.actionDescription': '将输入 Token 统一为包含缓存读取和缓存创建的统计口径。',
|
||||
'usage.dataManagement.run': '扫描并修复',
|
||||
'usage.dataManagement.running': '处理中…',
|
||||
'usage.dataManagement.confirm': '确定扫描并修复历史缓存记录吗?修复前会自动创建数据库备份。',
|
||||
'usage.dataManagement.scanned': '扫描记录',
|
||||
'usage.dataManagement.repaired': '修复记录',
|
||||
'usage.dataManagement.backup': '备份文件',
|
||||
'usage.trend.title': '请求与 Token 趋势',
|
||||
'usage.trend.description': '按系统本地小时聚合',
|
||||
'usage.token.title': 'Token 构成',
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Terminal,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { getCurrentLocale, useI18n } from '../i18n';
|
||||
@@ -29,7 +30,7 @@ import type { MessageKey } from '../i18n/resources';
|
||||
import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics';
|
||||
import { formatUsageNumber } from '../services/usageNumber';
|
||||
|
||||
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing';
|
||||
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing' | 'data-management';
|
||||
type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom';
|
||||
|
||||
type CollectorStatus = {
|
||||
@@ -158,6 +159,12 @@ type UsagePricing = {
|
||||
savedPrices: number;
|
||||
};
|
||||
|
||||
type UsageRepairResult = {
|
||||
scanned: number;
|
||||
repaired: number;
|
||||
backupPath: string | null;
|
||||
};
|
||||
|
||||
type ModelPriceSyncResult = {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
@@ -185,7 +192,9 @@ const emptyAnalysis: UsageAnalysis = { models: [], providers: [], sources: [], a
|
||||
const loadTab = (): UsageTab => {
|
||||
try {
|
||||
const saved = localStorage.getItem(TAB_KEY);
|
||||
return saved === 'analysis' || saved === 'events' || saved === 'pricing' ? saved : 'overview';
|
||||
return saved === 'analysis' || saved === 'events' || saved === 'pricing' || saved === 'data-management'
|
||||
? saved
|
||||
: 'overview';
|
||||
} catch {
|
||||
return 'overview';
|
||||
}
|
||||
@@ -356,7 +365,7 @@ export function UsageRecordsPage() {
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
setEvents(nextEvents);
|
||||
} else {
|
||||
} else if (activeTab === 'pricing') {
|
||||
const [nextStatus, nextOptions, nextPricing] = await Promise.all([
|
||||
statusRequest,
|
||||
optionsRequest,
|
||||
@@ -366,6 +375,11 @@ export function UsageRecordsPage() {
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
setPricing(nextPricing);
|
||||
} else {
|
||||
const [nextStatus, nextOptions] = await Promise.all([statusRequest, optionsRequest]);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
}
|
||||
setError('');
|
||||
} catch (requestError) {
|
||||
@@ -475,6 +489,14 @@ export function UsageRecordsPage() {
|
||||
<CircleDollarSign size={15} />
|
||||
<span>{t('usage.tab.pricing')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={activeTab === 'data-management' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('data-management')}
|
||||
>
|
||||
<Wrench size={15} />
|
||||
<span>{t('usage.tab.dataManagement')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="usage-topbar-actions">
|
||||
@@ -677,6 +699,69 @@ export function UsageRecordsPage() {
|
||||
{activeTab === 'pricing' && pricing ? (
|
||||
<PricingView pricing={pricing} query={buildQueries().query} onChanged={() => loadData(true)} />
|
||||
) : null}
|
||||
{activeTab === 'data-management' ? <UsageDataManagementView /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageDataManagementView() {
|
||||
const { t } = useI18n();
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<UsageRepairResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const repair = async () => {
|
||||
if (!window.confirm(t('usage.dataManagement.confirm'))) return;
|
||||
setRunning(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const next = await invoke<UsageRepairResult>('repair_usage_cache_records');
|
||||
setResult(next);
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel usage-data-management-panel">
|
||||
<div className="usage-data-management-heading">
|
||||
<div>
|
||||
<Wrench size={20} aria-hidden="true" />
|
||||
<div>
|
||||
<h2>{t('usage.dataManagement.title')}</h2>
|
||||
<p>{t('usage.dataManagement.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="usage-data-management-badge">{t('usage.dataManagement.manualBadge')}</span>
|
||||
</div>
|
||||
|
||||
<div className="usage-data-management-notice">
|
||||
<TriangleAlert size={17} aria-hidden="true" />
|
||||
<span>{t('usage.dataManagement.notice')}</span>
|
||||
</div>
|
||||
|
||||
<div className="usage-data-management-action">
|
||||
<div>
|
||||
<strong>{t('usage.dataManagement.actionTitle')}</strong>
|
||||
<span>{t('usage.dataManagement.actionDescription')}</span>
|
||||
</div>
|
||||
<button type="button" className="primary-button" onClick={() => void repair()} disabled={running}>
|
||||
<Wrench size={15} />
|
||||
{running ? t('usage.dataManagement.running') : t('usage.dataManagement.run')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{result ? (
|
||||
<div className="usage-data-management-result">
|
||||
<div><span>{t('usage.dataManagement.scanned')}</span><strong>{result.scanned.toLocaleString()}</strong></div>
|
||||
<div><span>{t('usage.dataManagement.repaired')}</span><strong>{result.repaired.toLocaleString()}</strong></div>
|
||||
<div><span>{t('usage.dataManagement.backup')}</span><strong title={result.backupPath ?? undefined}>{result.backupPath ?? '—'}</strong></div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+100
@@ -7718,6 +7718,95 @@ body.table-col-resizing * {
|
||||
background: var(--theme-fffdf8);
|
||||
}
|
||||
|
||||
.usage-data-management-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.usage-data-management-heading,
|
||||
.usage-data-management-action,
|
||||
.usage-data-management-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.usage-data-management-heading > div {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.usage-data-management-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.usage-data-management-heading p,
|
||||
.usage-data-management-action span {
|
||||
display: block;
|
||||
margin: 6px 0 0;
|
||||
color: var(--theme-716b61);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.usage-data-management-badge {
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 999px;
|
||||
color: var(--theme-716b61);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-data-management-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 22px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--theme-c77b30) 28%, var(--theme-d8d4cc));
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--theme-c77b30) 8%, transparent);
|
||||
color: var(--theme-716b61);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.usage-data-management-action {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 12px;
|
||||
background: var(--theme-f8f5ee);
|
||||
}
|
||||
|
||||
.usage-data-management-result {
|
||||
align-items: stretch;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.usage-data-management-result > div {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.usage-data-management-result span {
|
||||
color: var(--theme-716b61);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-data-management-result strong {
|
||||
overflow: hidden;
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-pricing-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -9010,6 +9099,17 @@ body.table-col-resizing * {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.usage-data-management-heading,
|
||||
.usage-data-management-action,
|
||||
.usage-data-management-result {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.usage-data-management-action .primary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user