feat(usage): add confirmed database cleanup controls
This commit is contained in:
1 parent
93525f0ac7
commit
bd57545c7f
7 files changed
+181
-15
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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': '扫描并修复',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user