feat(usage): add configurable database size limit
This commit is contained in:
1 parent
1f347d142b
commit
911a20a91a
7 files changed
+608
-65
No files matched your search
@@ -2648,7 +2648,9 @@ fn main() {
|
||||
usage::get_usage_analysis,
|
||||
usage::get_usage_events,
|
||||
usage::get_usage_pricing,
|
||||
usage::get_usage_storage_settings,
|
||||
usage::repair_usage_cache_records,
|
||||
usage::save_usage_storage_settings,
|
||||
usage::save_usage_model_price,
|
||||
usage::delete_usage_model_price,
|
||||
usage::sync_usage_model_prices,
|
||||
|
||||
+388
-42
@@ -44,6 +44,7 @@ const LEGACY_JSON_MIGRATION_KEY: &str = "legacy_json_v1";
|
||||
const USAGE_DATABASE_MIGRATION_KEY: &str = "keeper_v3";
|
||||
const USAGE_FAILURE_MIGRATION_KEY: &str = "failure_details_v4";
|
||||
const USAGE_EVENT_KEY_MIGRATION_KEY: &str = "event_key_v5";
|
||||
const USAGE_DATABASE_MAX_MB_KEY: &str = "max_database_size_mb";
|
||||
const USAGE_UPDATED_EVENT: &str = "usage-records-updated";
|
||||
const USAGE_SCHEMA_VERSION: u8 = 1;
|
||||
const USAGE_DATABASE_SCHEMA_VERSION: i64 = 5;
|
||||
@@ -56,6 +57,7 @@ const USAGE_QUEUE_KEY: &str = "usage";
|
||||
const LEGACY_USAGE_QUEUE_KEY: &str = "queue";
|
||||
const HTTP_USAGE_QUEUE_SOURCE: &str = "http_pull:usage_queue";
|
||||
const SQLITE_BUSY_TIMEOUT_SECONDS: u64 = 5;
|
||||
const BYTES_PER_MB: u64 = 1024 * 1024;
|
||||
const TOKENS_PER_PRICE_UNIT: f64 = 1_000_000.0;
|
||||
const LONG_CONTEXT_INPUT_TOKEN_THRESHOLD: u64 = 272_000;
|
||||
const BUNDLED_MODEL_PRICE_CATALOG: &str = include_str!("../resources/model_prices.json");
|
||||
@@ -105,6 +107,19 @@ struct UsageInboxRow {
|
||||
attempt_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct UsagePersistOutcome {
|
||||
inserted: usize,
|
||||
deleted: u64,
|
||||
}
|
||||
|
||||
impl UsagePersistOutcome {
|
||||
fn add(&mut self, other: Self) {
|
||||
self.inserted = self.inserted.saturating_add(other.inserted);
|
||||
self.deleted = self.deleted.saturating_add(other.deleted);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct UsageMigrationSnapshot {
|
||||
records: i64,
|
||||
@@ -305,6 +320,15 @@ pub(crate) struct UsageRepairResult {
|
||||
backup_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UsageStorageSettings {
|
||||
max_database_size_mb: u64,
|
||||
database_size_bytes: u64,
|
||||
total_records: u64,
|
||||
deleted_records: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ModelPrice {
|
||||
@@ -487,9 +511,13 @@ impl UsageCollectorState {
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_total_records(&self, added: usize) {
|
||||
fn apply_record_changes(&self, inserted: usize, deleted: u64) {
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
inner.status.total_records = inner.status.total_records.saturating_add(added as u64);
|
||||
inner.status.total_records = inner
|
||||
.status
|
||||
.total_records
|
||||
.saturating_add(inserted as u64)
|
||||
.saturating_sub(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,7 +607,8 @@ fn initialize_usage_storage_at(root: &Path) -> Result<(), String> {
|
||||
.map_err(|error| format!("启用 SQLite WAL 失败: {error}"))?;
|
||||
migrate_usage_database(&mut connection, root)?;
|
||||
migrate_legacy_json_storage(&mut connection, root)?;
|
||||
cleanup_usage_inbox(&connection, Local::now())
|
||||
cleanup_usage_inbox(&connection, Local::now())?;
|
||||
enforce_usage_database_limit(&connection).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -587,6 +616,37 @@ pub(crate) async fn repair_usage_cache_records() -> Result<UsageRepairResult, St
|
||||
run_usage_task(|| repair_usage_cache_records_at(&usage_root_dir()?)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_usage_storage_settings() -> Result<UsageStorageSettings, String> {
|
||||
run_usage_task(|| {
|
||||
let root = usage_root_dir()?;
|
||||
let connection = open_usage_database_at(&root)?;
|
||||
load_usage_storage_settings(&connection, &root, 0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn save_usage_storage_settings(
|
||||
app: tauri::AppHandle,
|
||||
max_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)?;
|
||||
save_usage_database_limit(&connection, max_database_size_mb)?;
|
||||
let deleted = enforce_usage_database_limit(&connection)?;
|
||||
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
|
||||
@@ -1329,6 +1389,198 @@ fn open_usage_database_at(root: &Path) -> Result<Connection, String> {
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
fn load_usage_database_limit(connection: &Connection) -> Result<u64, String> {
|
||||
let value = connection
|
||||
.query_row(
|
||||
"SELECT value FROM usage_metadata WHERE key = ?1",
|
||||
params![USAGE_DATABASE_MAX_MB_KEY],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| format!("读取使用记录数据库大小限制失败: {error}"))?;
|
||||
value
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<u64>()
|
||||
.map_err(|error| format!("使用记录数据库大小限制无效: {error}"))
|
||||
})
|
||||
.transpose()
|
||||
.map(|value| value.unwrap_or(0))
|
||||
}
|
||||
|
||||
fn save_usage_database_limit(
|
||||
connection: &Connection,
|
||||
max_database_size_mb: u64,
|
||||
) -> Result<(), String> {
|
||||
max_database_size_mb
|
||||
.checked_mul(BYTES_PER_MB)
|
||||
.ok_or_else(|| "使用记录数据库大小限制过大".to_string())?;
|
||||
connection
|
||||
.execute(
|
||||
r#"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value"#,
|
||||
params![USAGE_DATABASE_MAX_MB_KEY, max_database_size_mb.to_string()],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("保存使用记录数据库大小限制失败: {error}"))
|
||||
}
|
||||
|
||||
fn usage_database_active_bytes(connection: &Connection) -> Result<u64, String> {
|
||||
let page_size = connection
|
||||
.query_row("PRAGMA page_size", [], |row| row.get::<_, u64>(0))
|
||||
.map_err(|error| format!("读取使用记录数据库页大小失败: {error}"))?;
|
||||
let page_count = connection
|
||||
.query_row("PRAGMA page_count", [], |row| row.get::<_, u64>(0))
|
||||
.map_err(|error| format!("读取使用记录数据库页数失败: {error}"))?;
|
||||
let free_pages = connection
|
||||
.query_row("PRAGMA freelist_count", [], |row| row.get::<_, u64>(0))
|
||||
.map_err(|error| format!("读取使用记录数据库空闲页数失败: {error}"))?;
|
||||
page_count
|
||||
.saturating_sub(free_pages)
|
||||
.checked_mul(page_size)
|
||||
.ok_or_else(|| "使用记录数据库大小溢出".to_string())
|
||||
}
|
||||
|
||||
fn usage_database_allocated_bytes(connection: &Connection) -> Result<u64, String> {
|
||||
let page_size = connection
|
||||
.query_row("PRAGMA page_size", [], |row| row.get::<_, u64>(0))
|
||||
.map_err(|error| format!("读取使用记录数据库页大小失败: {error}"))?;
|
||||
let page_count = connection
|
||||
.query_row("PRAGMA page_count", [], |row| row.get::<_, u64>(0))
|
||||
.map_err(|error| format!("读取使用记录数据库页数失败: {error}"))?;
|
||||
page_count
|
||||
.checked_mul(page_size)
|
||||
.ok_or_else(|| "使用记录数据库大小溢出".to_string())
|
||||
}
|
||||
|
||||
fn usage_database_file_bytes(database_path: &Path) -> Result<u64, String> {
|
||||
let mut wal_path = database_path.as_os_str().to_os_string();
|
||||
wal_path.push("-wal");
|
||||
let paths = [database_path.to_path_buf(), PathBuf::from(wal_path)];
|
||||
let mut total = 0_u64;
|
||||
for path in paths {
|
||||
match fs::metadata(&path) {
|
||||
Ok(metadata) => total = total.saturating_add(metadata.len()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取使用记录数据库文件大小失败 {}: {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn usage_database_disk_bytes(root: &Path) -> Result<u64, String> {
|
||||
usage_database_file_bytes(&root.join(USAGE_DATABASE_FILE))
|
||||
}
|
||||
|
||||
fn usage_database_connection_disk_bytes(connection: &Connection) -> Result<u64, String> {
|
||||
let path = connection
|
||||
.path()
|
||||
.ok_or_else(|| "无法读取使用记录数据库文件路径".to_string())?;
|
||||
usage_database_file_bytes(Path::new(path))
|
||||
}
|
||||
|
||||
fn truncate_usage_database_wal(connection: &Connection) {
|
||||
let _ = connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
fn load_usage_storage_settings(
|
||||
connection: &Connection,
|
||||
root: &Path,
|
||||
deleted_records: u64,
|
||||
) -> Result<UsageStorageSettings, String> {
|
||||
let total_records = connection
|
||||
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
|
||||
row.get::<_, u64>(0)
|
||||
})
|
||||
.map_err(|error| format!("统计使用记录数量失败: {error}"))?;
|
||||
Ok(UsageStorageSettings {
|
||||
max_database_size_mb: load_usage_database_limit(connection)?,
|
||||
database_size_bytes: usage_database_disk_bytes(root)?,
|
||||
total_records,
|
||||
deleted_records,
|
||||
})
|
||||
}
|
||||
|
||||
fn enforce_usage_database_limit(connection: &Connection) -> Result<u64, String> {
|
||||
let max_database_size_mb = load_usage_database_limit(connection)?;
|
||||
if max_database_size_mb == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let max_bytes = max_database_size_mb
|
||||
.checked_mul(BYTES_PER_MB)
|
||||
.ok_or_else(|| "使用记录数据库大小限制过大".to_string())?;
|
||||
|
||||
if usage_database_connection_disk_bytes(connection)? <= max_bytes {
|
||||
return Ok(0);
|
||||
}
|
||||
truncate_usage_database_wal(connection);
|
||||
let allocated_bytes = usage_database_allocated_bytes(connection)?;
|
||||
if allocated_bytes <= max_bytes {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let inbox_deleted = connection
|
||||
.execute(
|
||||
"DELETE FROM usage_inbox WHERE status IN ('processed', 'decode_failed', 'discarded')",
|
||||
[],
|
||||
)
|
||||
.map_err(|error| format!("清理使用记录临时数据失败: {error}"))?;
|
||||
let mut active_bytes = usage_database_active_bytes(connection)?;
|
||||
let mut deleted_records = 0_u64;
|
||||
|
||||
while active_bytes > max_bytes {
|
||||
let total_records = connection
|
||||
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
|
||||
row.get::<_, u64>(0)
|
||||
})
|
||||
.map_err(|error| format!("统计待清理使用记录失败: {error}"))?;
|
||||
if total_records == 0 {
|
||||
break;
|
||||
}
|
||||
let excess = active_bytes.saturating_sub(max_bytes);
|
||||
let estimated = ((total_records as u128 * excess as u128)
|
||||
.saturating_add(active_bytes.saturating_sub(1) as u128)
|
||||
/ active_bytes.max(1) as u128) as u64;
|
||||
let batch = estimated.max(1).min(total_records).min(i64::MAX as u64) as i64;
|
||||
let deleted = connection
|
||||
.execute(
|
||||
r#"DELETE FROM usage_events
|
||||
WHERE id IN (
|
||||
SELECT id FROM usage_events
|
||||
ORDER BY timestamp_ms ASC, id ASC
|
||||
LIMIT ?1
|
||||
)"#,
|
||||
params![batch],
|
||||
)
|
||||
.map_err(|error| format!("删除最旧使用记录失败: {error}"))?
|
||||
as u64;
|
||||
if deleted == 0 {
|
||||
break;
|
||||
}
|
||||
deleted_records = deleted_records.saturating_add(deleted);
|
||||
active_bytes = usage_database_active_bytes(connection)?;
|
||||
}
|
||||
|
||||
if allocated_bytes > max_bytes || inbox_deleted > 0 || deleted_records > 0 {
|
||||
connection
|
||||
.execute_batch("VACUUM;")
|
||||
.map_err(|error| format!("回收使用记录数据库空间失败: {error}"))?;
|
||||
truncate_usage_database_wal(connection);
|
||||
}
|
||||
Ok(deleted_records)
|
||||
}
|
||||
|
||||
fn migrate_legacy_json_storage(connection: &mut Connection, root: &Path) -> Result<(), String> {
|
||||
let migrated = connection
|
||||
.query_row(
|
||||
@@ -1536,10 +1788,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) {
|
||||
let recovered = open_usage_database_at(&root)
|
||||
.and_then(|mut connection| process_usage_inbox(&mut connection, &config));
|
||||
match recovered {
|
||||
Ok(saved) if saved > 0 => {
|
||||
Ok(outcome) if outcome.inserted > 0 || outcome.deleted > 0 => {
|
||||
let saved = outcome.inserted;
|
||||
let collected_at = Local::now().to_rfc3339();
|
||||
app.state::<UsageCollectorState>()
|
||||
.increment_total_records(saved);
|
||||
.apply_record_changes(outcome.inserted, outcome.deleted);
|
||||
set_collector_status(
|
||||
&app,
|
||||
"collecting",
|
||||
@@ -1586,10 +1839,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) {
|
||||
subscription_config = Some((config.port, config.management_secret_key.clone()));
|
||||
set_collector_status(&app, "collecting", "已连接 CPA usage 实时订阅", None);
|
||||
match backfill_usage_queue(&root, &config, &mut redis_queue).await {
|
||||
Ok(saved) => {
|
||||
Ok(outcome) => {
|
||||
let saved = outcome.inserted;
|
||||
publish_collected_records(
|
||||
&app,
|
||||
saved,
|
||||
outcome,
|
||||
&format!("实时订阅已连接,补录 {saved} 条队列记录"),
|
||||
);
|
||||
retry_seconds = 1;
|
||||
@@ -1634,10 +1888,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) {
|
||||
payload,
|
||||
&config,
|
||||
) {
|
||||
Ok(saved) => {
|
||||
Ok(outcome) => {
|
||||
let saved = outcome.inserted;
|
||||
publish_collected_records(
|
||||
&app,
|
||||
saved,
|
||||
outcome,
|
||||
&format!("实时订阅已保存 {saved} 条新记录"),
|
||||
);
|
||||
retry_seconds = 1;
|
||||
@@ -1692,10 +1947,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) {
|
||||
batch.messages,
|
||||
&config,
|
||||
) {
|
||||
Ok(saved) => {
|
||||
Ok(outcome) => {
|
||||
let saved = outcome.inserted;
|
||||
publish_collected_records(
|
||||
&app,
|
||||
saved,
|
||||
outcome,
|
||||
&format!("{source_label}已保存 {saved} 条新记录"),
|
||||
);
|
||||
retry_seconds = 1;
|
||||
@@ -1720,27 +1976,27 @@ async fn backfill_usage_queue(
|
||||
root: &Path,
|
||||
config: &GuiConfigFile,
|
||||
redis_queue: &mut RedisUsageQueueSource,
|
||||
) -> Result<usize, String> {
|
||||
let mut saved_total = 0_usize;
|
||||
) -> Result<UsagePersistOutcome, String> {
|
||||
let mut outcome = UsagePersistOutcome::default();
|
||||
loop {
|
||||
match redis_queue.pull(config).await {
|
||||
Ok(batch) => {
|
||||
let fetched = batch.messages.len();
|
||||
if fetched == 0 {
|
||||
return Ok(saved_total);
|
||||
return Ok(outcome);
|
||||
}
|
||||
saved_total = saved_total.saturating_add(persist_raw_usage_messages_from_source(
|
||||
outcome.add(persist_raw_usage_messages_from_source(
|
||||
root,
|
||||
&batch.source,
|
||||
batch.messages,
|
||||
config,
|
||||
)?);
|
||||
if fetched < USAGE_QUEUE_BATCH_SIZE {
|
||||
return Ok(saved_total);
|
||||
return Ok(outcome);
|
||||
}
|
||||
}
|
||||
Err(redis_error) => {
|
||||
return backfill_http_usage_queue(root, config, saved_total)
|
||||
return backfill_http_usage_queue(root, config, outcome)
|
||||
.await
|
||||
.map_err(|http_error| {
|
||||
format!("补录队列失败(Redis: {redis_error}; HTTP: {http_error})")
|
||||
@@ -1753,31 +2009,32 @@ async fn backfill_usage_queue(
|
||||
async fn backfill_http_usage_queue(
|
||||
root: &Path,
|
||||
config: &GuiConfigFile,
|
||||
mut saved_total: usize,
|
||||
) -> Result<usize, String> {
|
||||
mut outcome: UsagePersistOutcome,
|
||||
) -> Result<UsagePersistOutcome, String> {
|
||||
loop {
|
||||
let messages = fetch_usage_queue_raw(config).await?;
|
||||
let fetched = messages.len();
|
||||
if fetched == 0 {
|
||||
return Ok(saved_total);
|
||||
return Ok(outcome);
|
||||
}
|
||||
saved_total = saved_total.saturating_add(persist_raw_usage_messages_from_source(
|
||||
outcome.add(persist_raw_usage_messages_from_source(
|
||||
root,
|
||||
HTTP_USAGE_QUEUE_SOURCE,
|
||||
messages,
|
||||
config,
|
||||
)?);
|
||||
if fetched < USAGE_QUEUE_BATCH_SIZE {
|
||||
return Ok(saved_total);
|
||||
return Ok(outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_collected_records(app: &tauri::AppHandle, saved: usize, message: &str) {
|
||||
fn publish_collected_records(app: &tauri::AppHandle, outcome: UsagePersistOutcome, message: &str) {
|
||||
let saved = outcome.inserted;
|
||||
let collected_at = Local::now().to_rfc3339();
|
||||
if saved > 0 {
|
||||
if saved > 0 || outcome.deleted > 0 {
|
||||
app.state::<UsageCollectorState>()
|
||||
.increment_total_records(saved);
|
||||
.apply_record_changes(saved, outcome.deleted);
|
||||
}
|
||||
set_collector_status(
|
||||
app,
|
||||
@@ -1805,9 +2062,9 @@ pub(crate) fn persist_local_usage_event(
|
||||
}
|
||||
let config = app.state::<GuiConfigState>().snapshot()?;
|
||||
let root = usage_root_dir()?;
|
||||
let saved = persist_queue_items_from_source(&root, collector_source, vec![value], &config)?;
|
||||
publish_collected_records(app, saved, "已保存桌面健康检测使用记录");
|
||||
Ok(saved)
|
||||
let outcome = persist_queue_items_from_source(&root, collector_source, vec![value], &config)?;
|
||||
publish_collected_records(app, outcome, "已保存桌面健康检测使用记录");
|
||||
Ok(outcome.inserted)
|
||||
}
|
||||
|
||||
async fn wait_or_cancel(token: &CancellationToken, seconds: u64) {
|
||||
@@ -1909,7 +2166,7 @@ fn persist_queue_items_from_source(
|
||||
source: &str,
|
||||
items: Vec<Value>,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<usize, String> {
|
||||
) -> Result<UsagePersistOutcome, String> {
|
||||
let mut connection = open_usage_database_at(root)?;
|
||||
enqueue_usage_queue_items(&mut connection, source, items)?;
|
||||
process_usage_inbox(&mut connection, config)
|
||||
@@ -2009,7 +2266,7 @@ fn persist_raw_usage_message_from_source(
|
||||
source: &str,
|
||||
raw_message: String,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<usize, String> {
|
||||
) -> Result<UsagePersistOutcome, String> {
|
||||
persist_raw_usage_messages_from_source(root, source, vec![raw_message], config)
|
||||
}
|
||||
|
||||
@@ -2018,7 +2275,7 @@ fn persist_raw_usage_messages_from_source(
|
||||
source: &str,
|
||||
raw_messages: Vec<String>,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<usize, String> {
|
||||
) -> Result<UsagePersistOutcome, String> {
|
||||
let mut connection = open_usage_database_at(root)?;
|
||||
enqueue_usage_raw_messages(&mut connection, source, raw_messages)?;
|
||||
process_usage_inbox(&mut connection, config)
|
||||
@@ -2027,10 +2284,10 @@ fn persist_raw_usage_messages_from_source(
|
||||
fn process_usage_inbox(
|
||||
connection: &mut Connection,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<usize, String> {
|
||||
) -> Result<UsagePersistOutcome, String> {
|
||||
let rows = list_processable_usage_inbox(connection, USAGE_INBOX_PROCESS_LIMIT)?;
|
||||
if rows.is_empty() {
|
||||
return Ok(0);
|
||||
return Ok(UsagePersistOutcome::default());
|
||||
}
|
||||
let mut valid_rows = Vec::with_capacity(rows.len());
|
||||
let mut records = Vec::with_capacity(rows.len());
|
||||
@@ -2048,7 +2305,10 @@ fn process_usage_inbox(
|
||||
}
|
||||
}
|
||||
if records.is_empty() {
|
||||
return Ok(0);
|
||||
return Ok(UsagePersistOutcome {
|
||||
inserted: 0,
|
||||
deleted: enforce_usage_database_limit(connection)?,
|
||||
});
|
||||
}
|
||||
|
||||
let persist_result = (|| -> Result<usize, String> {
|
||||
@@ -2077,7 +2337,10 @@ fn process_usage_inbox(
|
||||
Ok(inserted)
|
||||
})();
|
||||
match persist_result {
|
||||
Ok(inserted) => Ok(inserted),
|
||||
Ok(inserted) => Ok(UsagePersistOutcome {
|
||||
inserted,
|
||||
deleted: enforce_usage_database_limit(connection)?,
|
||||
}),
|
||||
Err(error) => {
|
||||
mark_usage_inbox_process_failed(connection, &valid_rows, &error)?;
|
||||
Err(error)
|
||||
@@ -4067,12 +4330,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collector_total_records_are_cached_and_incremented() {
|
||||
fn collector_total_records_apply_insertions_and_retention_deletions() {
|
||||
let state = UsageCollectorState::default();
|
||||
state.set_total_records(40);
|
||||
state.increment_total_records(2);
|
||||
state.apply_record_changes(4, 2);
|
||||
|
||||
assert_eq!(state.status().unwrap().total_records, 42);
|
||||
|
||||
state.apply_record_changes(0, 100);
|
||||
assert_eq!(state.status().unwrap().total_records, 0);
|
||||
}
|
||||
|
||||
fn test_root(name: &str) -> PathBuf {
|
||||
@@ -4117,6 +4383,86 @@ mod tests {
|
||||
open_usage_database_at(root).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_database_limit_defaults_to_unlimited_and_persists() {
|
||||
let root = test_root("database-limit-persistence");
|
||||
let connection = open_test_database(&root);
|
||||
|
||||
assert_eq!(load_usage_database_limit(&connection).unwrap(), 0);
|
||||
save_usage_database_limit(&connection, 128).unwrap();
|
||||
drop(connection);
|
||||
|
||||
let connection = open_usage_database_at(&root).unwrap();
|
||||
assert_eq!(load_usage_database_limit(&connection).unwrap(), 128);
|
||||
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");
|
||||
let mut connection = open_test_database(&root);
|
||||
save_usage_database_limit(&connection, 1).unwrap();
|
||||
|
||||
let payload = "x".repeat(8 * 1024);
|
||||
let transaction = connection.transaction().unwrap();
|
||||
for timestamp_ms in 0_i64..512 {
|
||||
transaction
|
||||
.execute(
|
||||
r#"INSERT INTO usage_events (
|
||||
event_key, timestamp, timestamp_ms, local_hour, model,
|
||||
failure_body, created_at
|
||||
) VALUES (?1, ?2, ?3, '2026-01-01T00', 'retention-test', ?4, ?2)"#,
|
||||
params![
|
||||
format!("retention-{timestamp_ms}"),
|
||||
format!("2026-01-01T00:00:{timestamp_ms:03}Z"),
|
||||
timestamp_ms,
|
||||
payload,
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
transaction.commit().unwrap();
|
||||
|
||||
enqueue_usage_queue_items(
|
||||
&mut connection,
|
||||
"retention-test",
|
||||
vec![serde_json::json!({
|
||||
"timestamp": "2026-09-18T12:00:00+08:00",
|
||||
"request_id": "newest-retained-record",
|
||||
"model": "retention-test",
|
||||
"tokens": { "input_tokens": 1, "output_tokens": 1 }
|
||||
})],
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = process_usage_inbox(&mut connection, &GuiConfigFile::default()).unwrap();
|
||||
|
||||
assert_eq!(outcome.inserted, 1);
|
||||
assert!(outcome.deleted > 0);
|
||||
let (remaining, oldest_timestamp, newest_record): (u64, i64, u64) = connection
|
||||
.query_row(
|
||||
r#"SELECT COUNT(*), MIN(timestamp_ms),
|
||||
SUM(CASE WHEN request_id = 'newest-retained-record' THEN 1 ELSE 0 END)
|
||||
FROM usage_events"#,
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(remaining > 0);
|
||||
assert!(remaining < 513);
|
||||
assert!(oldest_timestamp > 0);
|
||||
assert_eq!(newest_record, 1);
|
||||
assert!(usage_database_active_bytes(&connection).unwrap() <= BYTES_PER_MB);
|
||||
assert!(usage_database_disk_bytes(&root).unwrap() <= BYTES_PER_MB);
|
||||
|
||||
let settings = load_usage_storage_settings(&connection, &root, outcome.deleted).unwrap();
|
||||
assert_eq!(settings.max_database_size_mb, 1);
|
||||
assert_eq!(settings.total_records, remaining);
|
||||
assert_eq!(settings.deleted_records, outcome.deleted);
|
||||
drop(connection);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repairs_legacy_claude_input_tokens_on_demand() {
|
||||
let root = test_root("claude-input-migration");
|
||||
@@ -4782,7 +5128,7 @@ mod tests {
|
||||
&config,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(inserted, 1);
|
||||
assert_eq!(inserted.inserted, 1);
|
||||
let connection = open_usage_database_at(&root).unwrap();
|
||||
let inbox_count = connection
|
||||
.query_row("SELECT COUNT(*) FROM usage_inbox", [], |row| {
|
||||
@@ -4825,7 +5171,7 @@ mod tests {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(inserted, 0);
|
||||
assert_eq!(inserted.inserted, 0);
|
||||
assert_eq!(event_count, 0);
|
||||
assert_eq!(status, "decode_failed");
|
||||
drop(connection);
|
||||
@@ -4883,7 +5229,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(inserted, 2);
|
||||
assert_eq!(inserted.inserted, 2);
|
||||
assert_eq!(events.total, 2);
|
||||
assert!(events.items.iter().all(|event| !event.failed));
|
||||
assert!(events
|
||||
@@ -5225,7 +5571,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(inserted, 1);
|
||||
assert_eq!(inserted.inserted, 1);
|
||||
assert_eq!(event_count, 1);
|
||||
assert_eq!(processed_count, 1);
|
||||
assert_eq!(failed_count, 1);
|
||||
@@ -5259,7 +5605,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let inserted = process_usage_inbox(&mut connection, &GuiConfigFile::default()).unwrap();
|
||||
assert_eq!(inserted, 2);
|
||||
assert_eq!(inserted.inserted, 2);
|
||||
let event_count = connection
|
||||
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
|
||||
+11
-2
@@ -448,9 +448,18 @@ export const jaOverrides = {
|
||||
'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました',
|
||||
'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?',
|
||||
'usage.dataManagement.title': '履歴データ管理',
|
||||
'usage.dataManagement.description': 'キャッシュ Token の不整合がある使用履歴をスキャンして修復します。',
|
||||
'usage.dataManagement.description': '使用履歴の保存容量を管理し、既知の履歴データ異常を修復します。',
|
||||
'usage.dataManagement.manualBadge': '手動操作',
|
||||
'usage.dataManagement.notice': '修復前にデータベースをバックアップします。判定可能な Claude / Anthropic の記録のみ処理します。',
|
||||
'usage.dataManagement.storageTitle': 'データベース容量上限',
|
||||
'usage.dataManagement.storageDescription': '0 にすると無制限です。上限に達すると古い使用履歴から自動的に削除します。',
|
||||
'usage.dataManagement.storageCurrent': '現在の使用量 {size} · {records} 件',
|
||||
'usage.dataManagement.storageInput': 'データベース容量上限(MB)',
|
||||
'usage.dataManagement.storageUnit': 'MB',
|
||||
'usage.dataManagement.storageSave': '上限を保存',
|
||||
'usage.dataManagement.storageSaving': '保存中…',
|
||||
'usage.dataManagement.storageInvalid': 'データベース容量上限には 0 または正の整数を入力してください。',
|
||||
'usage.dataManagement.storageSaved': 'データベース容量上限を保存しました。',
|
||||
'usage.dataManagement.storageSavedWithCleanup': 'データベース容量上限を保存し、古い記録を {deleted} 件削除しました。',
|
||||
'usage.dataManagement.actionTitle': 'キャッシュ記録を修復',
|
||||
'usage.dataManagement.actionDescription': 'キャッシュ読み取りと作成を含む入力 Token の集計方式に統一します。',
|
||||
'usage.dataManagement.run': 'スキャンして修復',
|
||||
|
||||
+11
-2
@@ -435,9 +435,18 @@ 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': 'Scan and repair known anomalies in historical usage records.',
|
||||
'usage.dataManagement.description': 'Manage usage record storage and repair known historical data anomalies.',
|
||||
'usage.dataManagement.manualBadge': 'Manual action',
|
||||
'usage.dataManagement.notice': 'A database backup is created first. Only cache anomalies and unknown records are processed.',
|
||||
'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.',
|
||||
'usage.dataManagement.storageCurrent': 'Currently using {size} · {records} records',
|
||||
'usage.dataManagement.storageInput': 'Database size limit (MB)',
|
||||
'usage.dataManagement.storageUnit': 'MB',
|
||||
'usage.dataManagement.storageSave': 'Save limit',
|
||||
'usage.dataManagement.storageSaving': 'Saving…',
|
||||
'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.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,9 +434,18 @@ export const zhCN = {
|
||||
'usage.pricing.deleted': '模型价格已删除,总览成本已更新',
|
||||
'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?',
|
||||
'usage.dataManagement.title': '历史数据管理',
|
||||
'usage.dataManagement.description': '扫描并修复历史使用记录中的已知数据异常。',
|
||||
'usage.dataManagement.description': '管理使用记录存储并修复历史数据中的已知异常。',
|
||||
'usage.dataManagement.manualBadge': '手动操作',
|
||||
'usage.dataManagement.notice': '修复前会自动备份数据库,仅处理缓存口径异常和 unknown 记录。',
|
||||
'usage.dataManagement.storageTitle': '数据库大小限制',
|
||||
'usage.dataManagement.storageDescription': '设为 0 时不限制;达到上限后自动删除最旧的使用记录。',
|
||||
'usage.dataManagement.storageCurrent': '当前占用 {size} · {records} 条记录',
|
||||
'usage.dataManagement.storageInput': '数据库大小上限(MB)',
|
||||
'usage.dataManagement.storageUnit': 'MB',
|
||||
'usage.dataManagement.storageSave': '保存限制',
|
||||
'usage.dataManagement.storageSaving': '保存中…',
|
||||
'usage.dataManagement.storageInvalid': '请输入 0 或正整数作为数据库大小上限。',
|
||||
'usage.dataManagement.storageSaved': '数据库大小限制已保存。',
|
||||
'usage.dataManagement.storageSavedWithCleanup': '数据库大小限制已保存,并删除了最旧的 {deleted} 条记录。',
|
||||
'usage.dataManagement.actionTitle': '修复历史异常记录',
|
||||
'usage.dataManagement.actionDescription': '修正错误缓存信息,并删除被误记为 unknown 的记录。',
|
||||
'usage.dataManagement.run': '扫描并修复',
|
||||
|
||||
@@ -170,6 +170,13 @@ type UsageRepairResult = {
|
||||
backupPath: string | null;
|
||||
};
|
||||
|
||||
type UsageStorageSettings = {
|
||||
maxDatabaseSizeMb: number;
|
||||
databaseSizeBytes: number;
|
||||
totalRecords: number;
|
||||
deletedRecords: number;
|
||||
};
|
||||
|
||||
type ModelPriceSyncResult = {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
@@ -240,6 +247,13 @@ const rangeQuery = (range: UsageRange, customStart: string, customEnd: string):
|
||||
|
||||
const compactNumber = (value: number) => formatUsageNumber(value, getCurrentLocale());
|
||||
|
||||
const formatStorageBytes = (value: number) => {
|
||||
const bytes = Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const formatUsd = (amount: number) => {
|
||||
if (!Number.isFinite(amount) || amount <= 0) return '$0.00';
|
||||
const maximumFractionDigits =
|
||||
@@ -715,6 +729,57 @@ function UsageDataManagementView() {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<UsageRepairResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [storage, setStorage] = useState<UsageStorageSettings | null>(null);
|
||||
const [limitDraft, setLimitDraft] = useState('0');
|
||||
const [loadingLimit, setLoadingLimit] = useState(true);
|
||||
const [savingLimit, setSavingLimit] = useState(false);
|
||||
const [storageNotice, setStorageNotice] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
invoke<UsageStorageSettings>('get_usage_storage_settings')
|
||||
.then((next) => {
|
||||
if (disposed) return;
|
||||
setStorage(next);
|
||||
setLimitDraft(String(next.maxDatabaseSizeMb));
|
||||
})
|
||||
.catch((requestError) => {
|
||||
if (!disposed) setError(String(requestError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) setLoadingLimit(false);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveStorageLimit = async () => {
|
||||
const normalized = limitDraft.trim();
|
||||
const maxDatabaseSizeMb = Number(normalized);
|
||||
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(maxDatabaseSizeMb)) {
|
||||
setError(t('usage.dataManagement.storageInvalid'));
|
||||
return;
|
||||
}
|
||||
setSavingLimit(true);
|
||||
setError('');
|
||||
setStorageNotice('');
|
||||
try {
|
||||
const next = await invoke<UsageStorageSettings>('save_usage_storage_settings', { maxDatabaseSizeMb });
|
||||
setStorage(next);
|
||||
setLimitDraft(String(next.maxDatabaseSizeMb));
|
||||
setStorageNotice(t(
|
||||
next.deletedRecords > 0
|
||||
? 'usage.dataManagement.storageSavedWithCleanup'
|
||||
: 'usage.dataManagement.storageSaved',
|
||||
{ deleted: next.deletedRecords.toLocaleString() },
|
||||
));
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setSavingLimit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const repair = async () => {
|
||||
if (!await askConfirmation({ title: t('usage.dataManagement.title'), message: t('usage.dataManagement.confirm') })) return;
|
||||
@@ -724,6 +789,8 @@ function UsageDataManagementView() {
|
||||
try {
|
||||
const next = await invoke<UsageRepairResult>('repair_usage_cache_records');
|
||||
setResult(next);
|
||||
const nextStorage = await invoke<UsageStorageSettings>('get_usage_storage_settings');
|
||||
setStorage(nextStorage);
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
@@ -745,11 +812,49 @@ function UsageDataManagementView() {
|
||||
<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 className="usage-data-management-action usage-storage-limit-action">
|
||||
<div>
|
||||
<strong>{t('usage.dataManagement.storageTitle')}</strong>
|
||||
<span>{t('usage.dataManagement.storageDescription')}</span>
|
||||
{storage ? (
|
||||
<small>
|
||||
{t('usage.dataManagement.storageCurrent', {
|
||||
size: formatStorageBytes(storage.databaseSizeBytes),
|
||||
records: compactNumber(storage.totalRecords),
|
||||
})}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="usage-storage-limit-editor">
|
||||
<label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
inputMode="numeric"
|
||||
value={limitDraft}
|
||||
disabled={loadingLimit || savingLimit}
|
||||
onChange={(event) => setLimitDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !loadingLimit && !savingLimit) void saveStorageLimit();
|
||||
}}
|
||||
aria-label={t('usage.dataManagement.storageInput')}
|
||||
/>
|
||||
<span>{t('usage.dataManagement.storageUnit')}</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => void saveStorageLimit()}
|
||||
disabled={loadingLimit || savingLimit}
|
||||
>
|
||||
{savingLimit ? t('usage.dataManagement.storageSaving') : t('usage.dataManagement.storageSave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{storageNotice ? <MessageNotice tone="success" message={storageNotice} onDismiss={() => setStorageNotice('')} /> : null}
|
||||
|
||||
<div className="usage-data-management-action">
|
||||
<div>
|
||||
<strong>{t('usage.dataManagement.actionTitle')}</strong>
|
||||
|
||||
+77
-14
@@ -9348,7 +9348,7 @@ body.table-col-resizing * {
|
||||
}
|
||||
|
||||
.usage-data-management-heading p,
|
||||
.usage-data-management-action span {
|
||||
.usage-data-management-action > div:first-child > span {
|
||||
display: block;
|
||||
margin: 6px 0 0;
|
||||
color: var(--theme-716b61);
|
||||
@@ -9363,26 +9363,59 @@ body.table-col-resizing * {
|
||||
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 {
|
||||
margin-top: 22px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 12px;
|
||||
background: var(--theme-f8f5ee);
|
||||
}
|
||||
|
||||
.usage-data-management-action + .usage-data-management-action {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.usage-storage-limit-action small {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--theme-8a847c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 7px;
|
||||
background: var(--theme-fffdf8);
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor input {
|
||||
width: 104px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor input:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor label > span {
|
||||
padding: 0 10px 0 4px;
|
||||
color: var(--theme-716b61);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-data-management-result {
|
||||
align-items: stretch;
|
||||
margin-top: 26px;
|
||||
@@ -10825,6 +10858,21 @@ body.table-col-resizing * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor input {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -14381,6 +14429,21 @@ body.table-col-resizing * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.usage-storage-limit-editor input {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.usage-custom-range {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in new issue
Block a user