feat(usage): add confirmed database cleanup controls

This commit is contained in:
lzt404 committed 2026-09-18 18:17:31 +08:00
1 parent 93525f0ac7
commit bd57545c7f
7 files changed
+181 -15

No files matched your search

+1
View File
@@ -2651,6 +2651,7 @@ fn main() {
usage::get_usage_storage_settings,
usage::repair_usage_cache_records,
usage::save_usage_storage_settings,
usage::shrink_usage_database,
usage::save_usage_model_price,
usage::delete_usage_model_price,
usage::sync_usage_model_prices,
+72
View File
@@ -647,6 +647,26 @@ pub(crate) async fn save_usage_storage_settings(
Ok(settings)
}
#[tauri::command]
pub(crate) async fn shrink_usage_database(
app: tauri::AppHandle,
target_database_size_mb: u64,
) -> Result<UsageStorageSettings, String> {
let settings = run_usage_task(move || {
let root = usage_root_dir()?;
let connection = open_usage_database_at(&root)?;
let deleted = shrink_usage_database_to_mb(&connection, target_database_size_mb)?;
load_usage_storage_settings(&connection, &root, deleted)
})
.await?;
app.state::<UsageCollectorState>()
.set_total_records(settings.total_records);
if settings.deleted_records > 0 {
let _ = app.emit(USAGE_UPDATED_EVENT, Local::now().to_rfc3339());
}
Ok(settings)
}
fn repair_usage_cache_records_at(root: &Path) -> Result<UsageRepairResult, String> {
let mut connection = open_usage_database_at(root)?;
let candidate_count: i64 = connection
@@ -1520,7 +1540,23 @@ fn enforce_usage_database_limit(connection: &Connection) -> Result<u64, String>
let max_bytes = max_database_size_mb
.checked_mul(BYTES_PER_MB)
.ok_or_else(|| "使用记录数据库大小限制过大".to_string())?;
prune_usage_database_to_bytes(connection, max_bytes)
}
fn shrink_usage_database_to_mb(
connection: &Connection,
target_database_size_mb: u64,
) -> Result<u64, String> {
if target_database_size_mb == 0 {
return Err("数据库瘦身目标必须大于 0 MB".to_string());
}
let target_bytes = target_database_size_mb
.checked_mul(BYTES_PER_MB)
.ok_or_else(|| "数据库瘦身目标过大".to_string())?;
prune_usage_database_to_bytes(connection, target_bytes)
}
fn prune_usage_database_to_bytes(connection: &Connection, max_bytes: u64) -> Result<u64, String> {
if usage_database_connection_disk_bytes(connection)? <= max_bytes {
return Ok(0);
}
@@ -4389,6 +4425,7 @@ mod tests {
let connection = open_test_database(&root);
assert_eq!(load_usage_database_limit(&connection).unwrap(), 0);
assert!(shrink_usage_database_to_mb(&connection, 0).is_err());
save_usage_database_limit(&connection, 128).unwrap();
drop(connection);
@@ -4398,6 +4435,41 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn one_time_database_shrink_keeps_the_automatic_limit() {
let root = test_root("one-time-database-shrink");
let mut connection = open_test_database(&root);
save_usage_database_limit(&connection, 128).unwrap();
let payload = "x".repeat(8 * 1024);
let transaction = connection.transaction().unwrap();
for timestamp_ms in 0_i64..320 {
transaction
.execute(
r#"INSERT INTO usage_events (
event_key, timestamp, timestamp_ms, local_hour,
failure_body, created_at
) VALUES (?1, ?2, ?3, '2026-01-01T00', ?4, ?2)"#,
params![
format!("one-time-shrink-{timestamp_ms}"),
format!("2026-01-01T00:00:{timestamp_ms:03}Z"),
timestamp_ms,
payload,
],
)
.unwrap();
}
transaction.commit().unwrap();
let deleted = shrink_usage_database_to_mb(&connection, 1).unwrap();
assert!(deleted > 0);
assert_eq!(load_usage_database_limit(&connection).unwrap(), 128);
assert!(usage_database_disk_bytes(&root).unwrap() <= BYTES_PER_MB);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn usage_database_limit_removes_oldest_records_after_collection() {
let root = test_root("database-limit-retention");
+10 -1
View File
@@ -448,7 +448,6 @@ export const jaOverrides = {
'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました',
'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?',
'usage.dataManagement.title': '履歴データ管理',
'usage.dataManagement.description': '使用履歴の保存容量を管理し、既知の履歴データ異常を修復します。',
'usage.dataManagement.manualBadge': '手動操作',
'usage.dataManagement.storageTitle': 'データベース容量上限',
'usage.dataManagement.storageDescription': '0 にすると無制限です。上限に達すると古い使用履歴から自動的に削除します。',
@@ -460,6 +459,16 @@ export const jaOverrides = {
'usage.dataManagement.storageInvalid': 'データベース容量上限には 0 または正の整数を入力してください。',
'usage.dataManagement.storageSaved': 'データベース容量上限を保存しました。',
'usage.dataManagement.storageSavedWithCleanup': 'データベース容量上限を保存し、古い記録を {deleted} 件削除しました。',
'usage.dataManagement.shrinkTitle': 'データベースをワンクリックで縮小',
'usage.dataManagement.shrinkDescription': 'データベースを目標サイズに調整します。確認後、目標サイズ以下になるまで古い使用履歴から削除します。',
'usage.dataManagement.shrinkInput': '縮小後の目標サイズ(MB)',
'usage.dataManagement.shrinkRun': '今すぐ縮小',
'usage.dataManagement.shrinking': '縮小中…',
'usage.dataManagement.shrinkInvalid': '縮小後の目標サイズには 0 より大きい整数を入力してください。',
'usage.dataManagement.shrinkConfirmTitle': 'データベース縮小の確認',
'usage.dataManagement.shrinkConfirm': 'データベースを {size} MB 以下に縮小しますか?古い使用履歴から削除され、この操作は元に戻せません。',
'usage.dataManagement.shrinkSuccess': '縮小が完了しました。古い記録を {deleted} 件削除し、現在の使用量は {size} です。',
'usage.dataManagement.shrinkNoCleanup': 'データベースを整理しました。現在の使用量は {size} のため、使用履歴は削除されませんでした。',
'usage.dataManagement.actionTitle': 'キャッシュ記録を修復',
'usage.dataManagement.actionDescription': 'キャッシュ読み取りと作成を含む入力 Token の集計方式に統一します。',
'usage.dataManagement.run': 'スキャンして修復',
+10 -1
View File
@@ -435,7 +435,6 @@ export const en: Record<MessageKey, string> = {
'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': 'Manage usage record storage and repair known historical data anomalies.',
'usage.dataManagement.manualBadge': 'Manual action',
'usage.dataManagement.storageTitle': 'Database size limit',
'usage.dataManagement.storageDescription': 'Set to 0 for no limit. When the limit is reached, the oldest usage records are deleted automatically.',
@@ -447,6 +446,16 @@ export const en: Record<MessageKey, string> = {
'usage.dataManagement.storageInvalid': 'Enter 0 or a positive integer for the database size limit.',
'usage.dataManagement.storageSaved': 'Database size limit saved.',
'usage.dataManagement.storageSavedWithCleanup': 'Database size limit saved and {deleted} oldest records deleted.',
'usage.dataManagement.shrinkTitle': 'One-click database cleanup',
'usage.dataManagement.shrinkDescription': 'Set the database to the target size. After confirmation, the oldest usage records are deleted until the database is at or below that size.',
'usage.dataManagement.shrinkInput': 'Cleanup target size (MB)',
'usage.dataManagement.shrinkRun': 'Clean up now',
'usage.dataManagement.shrinking': 'Cleaning up…',
'usage.dataManagement.shrinkInvalid': 'Enter a positive integer for the cleanup target size.',
'usage.dataManagement.shrinkConfirmTitle': 'Confirm database cleanup',
'usage.dataManagement.shrinkConfirm': 'Reduce the database to {size} MB or less? The oldest usage records will be deleted and this action cannot be undone.',
'usage.dataManagement.shrinkSuccess': 'Cleanup complete. Deleted {deleted} oldest records; the database now uses {size}.',
'usage.dataManagement.shrinkNoCleanup': 'Database cleanup complete. It currently uses {size}, so no usage records were deleted.',
'usage.dataManagement.actionTitle': 'Repair historical anomalies',
'usage.dataManagement.actionDescription': 'Correct Claude cache token accounting and delete records recorded as unknown usage.',
'usage.dataManagement.run': 'Scan and repair',
+10 -1
View File
@@ -434,7 +434,6 @@ export const zhCN = {
'usage.pricing.deleted': '模型价格已删除,总览成本已更新',
'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?',
'usage.dataManagement.title': '历史数据管理',
'usage.dataManagement.description': '管理使用记录存储并修复历史数据中的已知异常。',
'usage.dataManagement.manualBadge': '手动操作',
'usage.dataManagement.storageTitle': '数据库大小限制',
'usage.dataManagement.storageDescription': '设为 0 时不限制;达到上限后自动删除最旧的使用记录。',
@@ -446,6 +445,16 @@ export const zhCN = {
'usage.dataManagement.storageInvalid': '请输入 0 或正整数作为数据库大小上限。',
'usage.dataManagement.storageSaved': '数据库大小限制已保存。',
'usage.dataManagement.storageSavedWithCleanup': '数据库大小限制已保存,并删除了最旧的 {deleted} 条记录。',
'usage.dataManagement.shrinkTitle': '数据库一键瘦身',
'usage.dataManagement.shrinkDescription': '将数据库大小调整到目标大小,确认后将从最旧的使用记录开始删除,直到数据库小于或等于目标大小。',
'usage.dataManagement.shrinkInput': '瘦身目标大小(MB)',
'usage.dataManagement.shrinkRun': '一键瘦身',
'usage.dataManagement.shrinking': '瘦身中…',
'usage.dataManagement.shrinkInvalid': '请输入大于 0 的整数作为瘦身目标大小。',
'usage.dataManagement.shrinkConfirmTitle': '确认数据库瘦身',
'usage.dataManagement.shrinkConfirm': '确定将数据库瘦身到 {size} MB 以内吗?确认后将从最旧的使用记录开始删除,此操作无法撤销。',
'usage.dataManagement.shrinkSuccess': '瘦身完成,已删除最旧的 {deleted} 条记录,当前占用 {size}。',
'usage.dataManagement.shrinkNoCleanup': '数据库已整理,当前占用 {size},无需删除使用记录。',
'usage.dataManagement.actionTitle': '修复历史异常记录',
'usage.dataManagement.actionDescription': '修正错误缓存信息,并删除被误记为 unknown 的记录。',
'usage.dataManagement.run': '扫描并修复',
+75 -11
View File
@@ -733,6 +733,8 @@ function UsageDataManagementView() {
const [limitDraft, setLimitDraft] = useState('0');
const [loadingLimit, setLoadingLimit] = useState(true);
const [savingLimit, setSavingLimit] = useState(false);
const [shrinkDraft, setShrinkDraft] = useState('');
const [shrinking, setShrinking] = useState(false);
const [storageNotice, setStorageNotice] = useState('');
useEffect(() => {
@@ -781,6 +783,40 @@ function UsageDataManagementView() {
}
};
const shrinkDatabase = async () => {
const normalized = shrinkDraft.trim();
const targetDatabaseSizeMb = Number(normalized);
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(targetDatabaseSizeMb) || targetDatabaseSizeMb <= 0) {
setError(t('usage.dataManagement.shrinkInvalid'));
return;
}
const confirmed = await askConfirmation({
title: t('usage.dataManagement.shrinkConfirmTitle'),
message: t('usage.dataManagement.shrinkConfirm', { size: targetDatabaseSizeMb }),
});
if (!confirmed) return;
setShrinking(true);
setError('');
setStorageNotice('');
try {
const next = await invoke<UsageStorageSettings>('shrink_usage_database', { targetDatabaseSizeMb });
setStorage(next);
setStorageNotice(t(
next.deletedRecords > 0
? 'usage.dataManagement.shrinkSuccess'
: 'usage.dataManagement.shrinkNoCleanup',
{
deleted: next.deletedRecords.toLocaleString(),
size: formatStorageBytes(next.databaseSizeBytes),
},
));
} catch (requestError) {
setError(String(requestError));
} finally {
setShrinking(false);
}
};
const repair = async () => {
if (!await askConfirmation({ title: t('usage.dataManagement.title'), message: t('usage.dataManagement.confirm') })) return;
setRunning(true);
@@ -804,10 +840,7 @@ function UsageDataManagementView() {
<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>
<h2>{t('usage.dataManagement.title')}</h2>
</div>
<span className="usage-data-management-badge">{t('usage.dataManagement.manualBadge')}</span>
</div>
@@ -828,15 +861,14 @@ function UsageDataManagementView() {
<div className="usage-storage-limit-editor">
<label>
<input
type="number"
min="0"
step="1"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={limitDraft}
disabled={loadingLimit || savingLimit}
disabled={loadingLimit || savingLimit || shrinking || running}
onChange={(event) => setLimitDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !loadingLimit && !savingLimit) void saveStorageLimit();
if (event.key === 'Enter' && !loadingLimit && !savingLimit && !shrinking && !running) void saveStorageLimit();
}}
aria-label={t('usage.dataManagement.storageInput')}
/>
@@ -846,13 +878,45 @@ function UsageDataManagementView() {
type="button"
className="primary-button"
onClick={() => void saveStorageLimit()}
disabled={loadingLimit || savingLimit}
disabled={loadingLimit || savingLimit || shrinking || running}
>
{savingLimit ? t('usage.dataManagement.storageSaving') : t('usage.dataManagement.storageSave')}
</button>
</div>
</div>
<div className="usage-data-management-action">
<div>
<strong>{t('usage.dataManagement.shrinkTitle')}</strong>
<span>{t('usage.dataManagement.shrinkDescription')}</span>
</div>
<div className="usage-storage-limit-editor">
<label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={shrinkDraft}
disabled={savingLimit || shrinking || running}
onChange={(event) => setShrinkDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !savingLimit && !shrinking && !running) void shrinkDatabase();
}}
aria-label={t('usage.dataManagement.shrinkInput')}
/>
<span>{t('usage.dataManagement.storageUnit')}</span>
</label>
<button
type="button"
className="primary-button"
onClick={() => void shrinkDatabase()}
disabled={savingLimit || shrinking || running}
>
{shrinking ? t('usage.dataManagement.shrinking') : t('usage.dataManagement.shrinkRun')}
</button>
</div>
</div>
{storageNotice ? <MessageNotice tone="success" message={storageNotice} onDismiss={() => setStorageNotice('')} /> : null}
<div className="usage-data-management-action">
@@ -860,7 +924,7 @@ function UsageDataManagementView() {
<strong>{t('usage.dataManagement.actionTitle')}</strong>
<span>{t('usage.dataManagement.actionDescription')}</span>
</div>
<button type="button" className="primary-button" onClick={() => void repair()} disabled={running}>
<button type="button" className="primary-button" onClick={() => void repair()} disabled={running || savingLimit || shrinking}>
{running ? t('usage.dataManagement.running') : t('usage.dataManagement.run')}
</button>
</div>
+3 -1
View File
@@ -9399,7 +9399,9 @@ body.table-col-resizing * {
}
.usage-storage-limit-editor input {
width: 104px;
width: 168px;
min-height: 40px;
padding: 0 12px;
border: 0;
background: transparent;
box-shadow: none;