diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5b0597b..9562875 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -593,7 +593,9 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.59" + +version = "0.2.61" + dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c1c66a9..4cf8d3b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,9 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. -version = "0.2.59" + +version = "0.2.61" + description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" diff --git a/src-tauri/src/agents/state.rs b/src-tauri/src/agents/state.rs index a09b689..24dab78 100644 --- a/src-tauri/src/agents/state.rs +++ b/src-tauri/src/agents/state.rs @@ -1396,27 +1396,6 @@ pub(crate) fn restore_agent_applied_state_configuration( Ok(()) } -pub(crate) fn agent_clients_restored_on_exit() -> [AgentClient; 8] { - [ - AgentClient::Codex, - AgentClient::OpenCode, - AgentClient::OpenClaw, - AgentClient::Hermes, - AgentClient::DeepSeekHarness, - AgentClient::ZCode, - AgentClient::KimiCode, - AgentClient::GrokBuild, - ] -} - -pub(crate) fn restore_all_agent_session_configurations(home: &Path) { - for client in agent_clients_restored_on_exit() { - if let Err(error) = restore_agent_session_configuration(client, home) { - eprintln!("关闭时恢复 {} 配置失败: {error}", client.name()); - } - } -} - pub(crate) fn commit_agent_configuration( client: AgentClient, home: &Path, diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 9d10f74..9b73191 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -160,14 +160,6 @@ pub(crate) async fn ensure_claude_desktop_model_aliases( put_management_alias_config_changes(config, &content, &updated).await } -pub(crate) async fn remove_managed_claude_model_aliases( - config: &GuiConfigFile, -) -> Result<(), String> { - let content = fetch_management_config_yaml(config).await?; - let updated = remove_managed_claude_model_aliases_in_yaml(&content)?; - put_management_alias_config_changes(config, &content, &updated).await -} - pub(crate) fn ensure_claude_desktop_model_aliases_in_yaml( content: &str, mappings: &ClaudeDesktopModelMappings, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ef9b58f..99160ad 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2128,22 +2128,6 @@ fn main() { tauri::RunEvent::Exit => { usage::stop_usage_collector(app_handle); let gui_config_state = app_handle.state::(); - match gui_config_state.snapshot() { - Ok(config) => { - if let Err(error) = - tauri::async_runtime::block_on(remove_managed_claude_model_aliases(&config)) - { - eprintln!("退出时清理 EasyCLIProxyAPI 托管的 Claude 模型别名失败: {error}"); - } - } - Err(error) => { - eprintln!("退出时读取 GUI 配置失败,无法清理 Claude 模型别名: {error}"); - } - } - if let Ok(home) = app_handle.path().home_dir() { - let _guard = AGENT_CONFIG_FILE_LOCK.lock(); - restore_all_agent_session_configurations(&home); - } let process_state = app_handle.state::(); shutdown_managed_core(process_state.inner(), gui_config_state.inner()); } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 5d71d6e..54764a0 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -1414,19 +1414,6 @@ oauth-model-alias: assert!(find("oauth-fast").is_alias); } -#[test] -fn exit_preserves_claude_client_configurations() { - let restored = agent_clients_restored_on_exit(); - assert!(!restored.contains(&AgentClient::ClaudeCode)); - assert!(!restored.contains(&AgentClient::ClaudeDesktop)); - assert!(restored.contains(&AgentClient::Codex)); - assert!(restored.contains(&AgentClient::OpenCode)); - assert!(restored.contains(&AgentClient::DeepSeekHarness)); - assert!(restored.contains(&AgentClient::ZCode)); - assert!(restored.contains(&AgentClient::KimiCode)); - assert!(restored.contains(&AgentClient::GrokBuild)); -} - #[test] fn deepseek_harness_settings_preserve_unrelated_sections_and_publish_models() { let existing = r#"# keep this comment diff --git a/src-tauri/src/tests/agent_state.rs b/src-tauri/src/tests/agent_state.rs index 90be442..58de476 100644 --- a/src-tauri/src/tests/agent_state.rs +++ b/src-tauri/src/tests/agent_state.rs @@ -167,7 +167,7 @@ fn codex_oauth_requires_auth_json_with_tokens() { } #[test] -fn agent_configuration_is_restored_from_the_dated_session_backup_on_exit() { +fn agent_configuration_is_restored_from_the_dated_session_backup_when_closed() { let home = agent_test_home("session-backup"); let path = home.join(".config/opencode/opencode.json"); let original = b"{\"provider\":\"original\"}"; @@ -1271,6 +1271,45 @@ fn codex_session_restore_does_not_create_a_state_json_file() { fs::remove_dir_all(home).unwrap(); } +#[test] +fn codex_applied_configuration_remains_detectable_after_process_restart() { + let home = agent_test_home("codex-applied-across-restart"); + let config_path = home.join(".codex/config.toml"); + let catalog_path = codex_model_catalog_path(&home); + let state_path = agent_state_path(std::slice::from_ref(&config_path)).unwrap(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "approval_policy = \"never\"\n").unwrap(); + + commit_agent_configuration( + AgentClient::Codex, + &home, + "gpt-test", + &[ + AgentFileUpdate { + path: config_path.clone(), + after: "model = \"gpt-test\"\n".to_string(), + }, + AgentFileUpdate { + path: catalog_path, + after: "{\"models\":[]}".to_string(), + }, + ], + "applied", + None, + ) + .unwrap(); + + clear_codex_applied_state(&state_path).unwrap(); + + let inspection = inspect_agent_application(AgentClient::Codex, &home); + assert_eq!(inspection.state, "applied"); + assert_eq!(inspection.applied_model.as_deref(), Some("gpt-test")); + assert!(dated_agent_backup_path(&config_path).unwrap().is_file()); + + restore_agent_session_configuration(AgentClient::Codex, &home).unwrap(); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn codex_session_restore_recovers_from_a_dated_backup_without_state_json() { let home = agent_test_home("codex-session-backup-recovery"); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index c9a5e7a..caab1ea 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -33,9 +33,11 @@ const LEGACY_USAGE_EVENTS_DIR: &str = "events"; const LEGACY_USAGE_INBOX_DIR: &str = "inbox"; 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_UPDATED_EVENT: &str = "usage-records-updated"; const USAGE_SCHEMA_VERSION: u8 = 1; -const USAGE_DATABASE_SCHEMA_VERSION: i64 = 3; +const USAGE_DATABASE_SCHEMA_VERSION: i64 = 4; +const MAX_USAGE_FAILURE_BODY_CHARS: usize = 2_000; const USAGE_QUEUE_BATCH_SIZE: usize = 500; const USAGE_INBOX_PROCESS_LIMIT: usize = 500; const USAGE_INBOX_MAX_ATTEMPTS: i64 = 5; @@ -150,6 +152,12 @@ pub(crate) struct UsageRecord { #[serde(default)] failed: bool, #[serde(default)] + canceled: bool, + #[serde(default)] + failure_status: u16, + #[serde(default)] + failure_body: String, + #[serde(default)] provider: String, #[serde(default, skip_serializing)] api_group_key: String, @@ -224,6 +232,8 @@ pub(crate) struct UsageQuery { #[serde(default)] failed: Option, #[serde(default)] + canceled: Option, + #[serde(default)] page: Option, #[serde(default)] page_size: Option, @@ -235,6 +245,7 @@ pub(crate) struct UsageOverview { total_requests: u64, success_count: u64, failure_count: u64, + canceled_count: u64, success_rate: f64, input_tokens: u64, output_tokens: u64, @@ -355,6 +366,7 @@ struct UsageTimelinePoint { requests: u64, success: u64, failure: u64, + canceled: u64, tokens: u64, } @@ -736,6 +748,9 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> { source TEXT NOT NULL DEFAULT '', auth_index TEXT NOT NULL DEFAULT '', failed INTEGER NOT NULL DEFAULT 0, + canceled INTEGER NOT NULL DEFAULT 0, + failure_status INTEGER NOT NULL DEFAULT 0, + failure_body TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '', alias TEXT NOT NULL DEFAULT '', @@ -824,11 +839,111 @@ fn initialize_usage_schema(connection: &Connection) -> Result<(), String> { "#, ) .map_err(|error| format!("初始化 SQLite 使用记录结构失败: {error}"))?; + ensure_usage_failure_columns(connection)?; + connection + .execute( + "CREATE INDEX IF NOT EXISTS idx_usage_events_canceled_timestamp ON usage_events(canceled, timestamp_ms DESC)", + [], + ) + .map_err(|error| format!("创建 SQLite 取消记录索引失败: {error}"))?; connection .pragma_update(None, "user_version", USAGE_DATABASE_SCHEMA_VERSION) .map_err(|error| format!("更新 SQLite 使用记录版本失败: {error}")) } +fn ensure_usage_failure_columns(connection: &Connection) -> Result<(), String> { + let mut columns = usage_table_columns(connection, "usage_events")?; + for (column, definition) in [ + ("canceled", "INTEGER NOT NULL DEFAULT 0"), + ("failure_status", "INTEGER NOT NULL DEFAULT 0"), + ("failure_body", "TEXT NOT NULL DEFAULT ''"), + ] { + if columns.contains(column) { + continue; + } + connection + .execute( + &format!("ALTER TABLE usage_events ADD COLUMN {column} {definition}"), + [], + ) + .map_err(|error| format!("添加 SQLite 使用记录字段 {column} 失败: {error}"))?; + columns.insert(column.to_string()); + } + backfill_usage_failure_details(connection)?; + Ok(()) +} + +fn backfill_usage_failure_details(connection: &Connection) -> Result<(), String> { + let migrated = connection + .query_row( + "SELECT value FROM usage_metadata WHERE key = ?1", + params![USAGE_FAILURE_MIGRATION_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| format!("读取使用记录失败详情迁移状态失败: {error}"))?; + if migrated.is_some() { + return Ok(()); + } + + if usage_table_exists(connection, "usage_inbox")? { + let rows = { + let mut statement = connection + .prepare( + r#" + SELECT usage_event_key, raw_message + FROM usage_inbox + WHERE status = 'processed' AND usage_event_key != '' + "#, + ) + .map_err(|error| format!("准备回填使用记录失败详情失败: {error}"))?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| format!("查询使用记录失败详情失败: {error}"))? + .collect::, _>>() + .map_err(|error| format!("读取使用记录失败详情失败: {error}"))?; + rows + }; + for (event_key, raw_message) in rows { + let Some(object) = serde_json::from_str::(&raw_message) + .ok() + .and_then(|value| value.as_object().cloned()) + else { + continue; + }; + if !object + .get("failed") + .and_then(Value::as_bool) + .unwrap_or(false) + { + continue; + } + let (failure_status, failure_body) = usage_failure_details(&object); + let canceled = usage_failure_is_canceled(failure_status, &failure_body); + connection + .execute( + r#" + UPDATE usage_events + SET canceled = ?1, failure_status = ?2, failure_body = ?3 + WHERE event_key = ?4 + "#, + params![canceled, i64::from(failure_status), failure_body, event_key], + ) + .map_err(|error| format!("回填使用记录失败详情失败: {error}"))?; + } + } + + connection + .execute( + "INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)", + params![USAGE_FAILURE_MIGRATION_KEY, Local::now().to_rfc3339()], + ) + .map_err(|error| format!("记录使用记录失败详情迁移状态失败: {error}"))?; + Ok(()) +} + fn open_usage_database() -> Result { open_usage_database_at(&usage_root_dir()?) } @@ -1542,12 +1657,13 @@ fn insert_usage_records_in_transaction( api_group_key, model_alias, client_ip, x_forwarded_for, user_agent, generate, cached_tokens, collector_source, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, - cache_creation_tokens, total_tokens, created_at + cache_creation_tokens, total_tokens, canceled, failure_status, + failure_body, created_at ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, - ?31, ?32, ?33, ?34, ?35, ?36, ?37 + ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40 ) "#, ) @@ -1620,6 +1736,9 @@ fn insert_usage_records_in_transaction( to_sql_i64(record.tokens.cache_read_tokens), to_sql_i64(record.tokens.cache_creation_tokens), to_sql_i64(record.tokens.total_tokens), + record.canceled, + i64::from(record.failure_status), + record.failure_body, created_at, ]) .map_err(|error| format!("写入 SQLite 使用记录失败: {error}"))?, @@ -1673,15 +1792,18 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result Result Result String { + let body = match value { + Value::String(value) => value.trim().to_string(), + Value::Null => String::new(), + value => serde_json::to_string(value).unwrap_or_default(), + }; + if body.chars().count() <= MAX_USAGE_FAILURE_BODY_CHARS { + body + } else { + let mut bounded = body + .chars() + .take(MAX_USAGE_FAILURE_BODY_CHARS) + .collect::(); + bounded.push('…'); + bounded + } +} + +fn usage_failure_details(object: &serde_json::Map) -> (u16, String) { + let failure = object.get("fail").and_then(Value::as_object); + let status = failure + .and_then(|value| value.get("status_code").or_else(|| value.get("statusCode"))) + .and_then(Value::as_u64) + .unwrap_or_default() + .min(u16::MAX as u64) as u16; + let body = failure + .and_then(|value| value.get("body")) + .map(usage_failure_body) + .unwrap_or_default(); + (status, body) +} + +fn usage_failure_is_canceled(status: u16, body: &str) -> bool { + if status == 499 { + return true; + } + let body = body.to_ascii_lowercase(); + body.contains("context canceled") || body.contains("client closed request") +} + fn default_usage_generate() -> bool { true } @@ -1769,9 +1931,16 @@ fn build_usage_filter(query: &UsageQuery) -> UsageSqlFilter { "api_key_hash", query.api_key_hash.as_deref(), ); + if let Some(canceled) = query.canceled { + clauses.push("canceled = ?".to_string()); + params.push(SqlValue::Integer(i64::from(canceled))); + } if let Some(failed) = query.failed { - clauses.push("failed = ?".to_string()); - params.push(SqlValue::Integer(i64::from(failed))); + if failed { + clauses.push("failed != 0 AND canceled = 0".to_string()); + } else { + clauses.push("failed = 0".to_string()); + } } UsageSqlFilter { clause: if clauses.is_empty() { @@ -1818,7 +1987,8 @@ fn load_usage_overview( SELECT COUNT(*), COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN canceled != 0 THEN 1 ELSE 0 END), 0), COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), COALESCE(SUM(reasoning_tokens), 0), @@ -1852,9 +2022,10 @@ fn load_usage_overview( row.get::<_, i64>(7)?, row.get::<_, i64>(8)?, row.get::<_, i64>(9)?, - row.get::<_, f64>(10)?, - row.get::<_, Option>(11)?, + row.get::<_, i64>(10)?, + row.get::<_, f64>(11)?, row.get::<_, Option>(12)?, + row.get::<_, Option>(13)?, )) }, ) @@ -1868,7 +2039,8 @@ fn load_usage_overview( local_hour, COUNT(*), COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN canceled != 0 THEN 1 ELSE 0 END), 0), COALESCE(SUM(total_tokens), 0) FROM usage_events{} GROUP BY local_hour @@ -1886,7 +2058,8 @@ fn load_usage_overview( requests: from_sql_i64(row.get(1)?), success: from_sql_i64(row.get(2)?), failure: from_sql_i64(row.get(3)?), - tokens: from_sql_i64(row.get(4)?), + canceled: from_sql_i64(row.get(4)?), + tokens: from_sql_i64(row.get(5)?), }) }) .map_err(|error| format!("查询 SQLite 使用趋势失败: {error}"))? @@ -1897,28 +2070,34 @@ fn load_usage_overview( total_requests: from_sql_i64(summary.0), success_count: from_sql_i64(summary.1), failure_count: from_sql_i64(summary.2), - input_tokens: from_sql_i64(summary.3), - output_tokens: from_sql_i64(summary.4), - reasoning_tokens: from_sql_i64(summary.5), - cache_read_tokens: from_sql_i64(summary.6), - cache_creation_tokens: from_sql_i64(summary.7), - total_tokens: from_sql_i64(summary.8), + canceled_count: from_sql_i64(summary.3), + input_tokens: from_sql_i64(summary.4), + output_tokens: from_sql_i64(summary.5), + reasoning_tokens: from_sql_i64(summary.6), + cache_read_tokens: from_sql_i64(summary.7), + cache_creation_tokens: from_sql_i64(summary.8), + total_tokens: from_sql_i64(summary.9), estimated_cost, priced_requests, timeline, ..UsageOverview::default() }; if overview.total_requests > 0 { - overview.success_rate = - overview.success_count as f64 * 100.0 / overview.total_requests as f64; + let completed_requests = overview + .success_count + .saturating_add(overview.failure_count); + if completed_requests > 0 { + overview.success_rate = + overview.success_count as f64 * 100.0 / completed_requests as f64; + } overview.average_latency_ms = - from_sql_i64(summary.9) as f64 / overview.total_requests as f64; - overview.tps = summary.10; + from_sql_i64(summary.10) as f64 / overview.total_requests as f64; + overview.tps = summary.11; if overview.input_tokens > 0 { overview.cache_hit_rate = (overview.cache_read_tokens as f64 / overview.input_tokens as f64).min(1.0); } - let minutes = query_window_minutes(query, summary.11, summary.12); + let minutes = query_window_minutes(query, summary.12, summary.13); overview.rpm = overview.total_requests as f64 / minutes; overview.tpm = overview.total_tokens as f64 / minutes; } @@ -2586,7 +2765,7 @@ fn load_simple_categories( SELECT COALESCE(NULLIF(TRIM({column}), ''), ?), COUNT(*), - COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0), COALESCE(SUM(total_tokens), 0) FROM usage_events{} GROUP BY 1 @@ -2629,7 +2808,7 @@ fn load_api_key_categories( MAX(TRIM(api_key_remark)), MAX(TRIM(api_key_display)), COUNT(*), - COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN failed != 0 AND canceled = 0 THEN 1 ELSE 0 END), 0), COALESCE(SUM(total_tokens), 0) FROM usage_events{} GROUP BY 1 @@ -2699,7 +2878,8 @@ fn load_usage_events( api_group_key, client_ip, x_forwarded_for, user_agent, generate, cached_tokens, collector_source, input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, - cache_creation_tokens, total_tokens + cache_creation_tokens, total_tokens, canceled, failure_status, + failure_body FROM usage_events{} ORDER BY timestamp_ms DESC, id DESC LIMIT ? OFFSET ? @@ -2739,6 +2919,9 @@ fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result { source_display: String::new(), auth_index: row.get(5)?, failed: row.get::<_, i64>(6)? != 0, + canceled: row.get::<_, i64>(33)? != 0, + failure_status: row.get::<_, i64>(34)?.clamp(0, u16::MAX as i64) as u16, + failure_body: row.get(35)?, provider: row.get(7)?, api_group_key: row.get(20)?, model: row.get(8)?, @@ -3042,6 +3225,9 @@ mod tests { source_display: String::new(), auth_index: "auth".to_string(), failed: false, + canceled: false, + failure_status: 0, + failure_body: String::new(), provider: "openai".to_string(), api_group_key: "hash".to_string(), model: model.to_string(), @@ -3238,6 +3424,85 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn persists_failure_details_and_excludes_client_cancellations_from_failures() { + let root = test_root("usage-failure-details"); + initialize_usage_storage_at(&root).unwrap(); + let config = GuiConfigFile::default(); + persist_queue_items( + &root, + vec![ + serde_json::json!({ + "timestamp": "2026-07-29T10:00:00+08:00", + "request_id": "success", + "failed": false, + "provider": "antigravity", + "model": "gemini-test" + }), + serde_json::json!({ + "timestamp": "2026-07-29T10:00:01+08:00", + "request_id": "upstream-failure", + "failed": true, + "provider": "antigravity", + "model": "gemini-test", + "fail": { + "status_code": 429, + "body": { "error": { "message": "quota exhausted" } } + } + }), + serde_json::json!({ + "timestamp": "2026-07-29T10:00:02+08:00", + "request_id": "client-canceled", + "failed": true, + "provider": "antigravity", + "model": "gemini-test", + "fail": { + "status_code": 499, + "body": "context canceled" + } + }), + ], + &config, + ) + .unwrap(); + + let connection = open_usage_database_at(&root).unwrap(); + let overview = load_usage_overview(&connection, &UsageQuery::default()).unwrap(); + let failures = load_usage_events( + &connection, + &UsageQuery { + failed: Some(true), + ..UsageQuery::default() + }, + &config, + ) + .unwrap(); + let cancellations = load_usage_events( + &connection, + &UsageQuery { + canceled: Some(true), + ..UsageQuery::default() + }, + &config, + ) + .unwrap(); + + assert_eq!(overview.total_requests, 3); + assert_eq!(overview.success_count, 1); + assert_eq!(overview.failure_count, 1); + assert_eq!(overview.canceled_count, 1); + assert_eq!(overview.success_rate, 50.0); + assert_eq!(failures.total, 1); + assert_eq!(failures.items[0].request_id, "upstream-failure"); + assert_eq!(failures.items[0].failure_status, 429); + assert!(failures.items[0].failure_body.contains("quota exhausted")); + assert_eq!(cancellations.total, 1); + assert_eq!(cancellations.items[0].request_id, "client-canceled"); + assert!(cancellations.items[0].canceled); + drop(connection); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn sqlite_storage_uses_wal_and_reference_busy_timeout() { let root = test_root("sqlite-pragmas"); @@ -3339,6 +3604,86 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn failure_detail_migration_backfills_processed_inbox_rows_once() { + let root = test_root("failure-detail-v4-migration"); + let mut connection = open_test_database(&root); + let mut record = sample_record( + "legacy-canceled", + "2026-07-17T20:30:00+08:00", + "gemini-test", + ); + record.failed = true; + insert_usage_records(&mut connection, &[record]).unwrap(); + connection + .execute( + r#" + INSERT INTO usage_inbox ( + source, message_hash, raw_message, status, attempt_count, + usage_event_key, received_at, processed_at, created_at, updated_at + ) VALUES ( + 'test', 'legacy-canceled-hash', ?1, 'processed', 1, + 'legacy-canceled', ?2, ?2, ?2, ?2 + ) + "#, + params![ + serde_json::json!({ + "request_id": "legacy-canceled", + "failed": true, + "fail": { + "status_code": 499, + "body": "client closed request" + } + }) + .to_string(), + Local::now().to_rfc3339(), + ], + ) + .unwrap(); + connection + .execute( + "DELETE FROM usage_metadata WHERE key = ?1", + params![USAGE_FAILURE_MIGRATION_KEY], + ) + .unwrap(); + drop(connection); + + initialize_usage_storage_at(&root).unwrap(); + initialize_usage_storage_at(&root).unwrap(); + let connection = open_usage_database_at(&root).unwrap(); + let migrated = connection + .query_row( + r#" + SELECT canceled, failure_status, failure_body + FROM usage_events + WHERE event_key = 'legacy-canceled' + "#, + [], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .unwrap(); + let markers = connection + .query_row( + "SELECT COUNT(*) FROM usage_metadata WHERE key = ?1", + params![USAGE_FAILURE_MIGRATION_KEY], + |row| row.get::<_, i64>(0), + ) + .unwrap(); + + assert_eq!(migrated.0, 1); + assert_eq!(migrated.1, 499); + assert_eq!(migrated.2, "client closed request"); + assert_eq!(markers, 1); + drop(connection); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn durable_inbox_recovers_valid_rows_and_isolates_malformed_rows() { let root = test_root("durable-inbox"); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 04a8632..ad8668f 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -113,6 +113,8 @@ export const jaOverrides = { 'usage.tab.analysis': '分析', 'usage.tab.events': 'リクエスト詳細', 'usage.tab.pricing': '料金統計', + 'usage.refresh': '更新', + 'usage.filter.reset': 'フィルター解除', 'usage.filter.timeRange': '期間', 'usage.range.4h': '過去 4 時間', 'usage.range.24h': '過去 24 時間', @@ -131,12 +133,13 @@ export const jaOverrides = { 'usage.filter.allResults': 'すべての結果', 'usage.result.success': '成功', 'usage.result.failed': '失敗', + 'usage.result.canceled': 'キャンセル済み', 'usage.filter.startTime': '開始日時', 'usage.filter.endTime': '終了日時', 'usage.filter.to': '~', 'usage.loading': 'ローカル SQLite の使用履歴を読み込んでいます', 'usage.stat.requests': '総リクエスト数', - 'usage.stat.requestMeta': '成功 {success} · 失敗 {failed}', + 'usage.stat.requestMeta': '成功 {success} · 失敗 {failed} · キャンセル {canceled}', 'usage.stat.tokens': '合計 Token', 'usage.stat.tokenMeta': '入 {input} · 出 {output}', 'usage.stat.successRate': '成功率', @@ -188,9 +191,14 @@ export const jaOverrides = { 'usage.analysis.itemMeta': '{requests} 回 · {percent}% · {tokens} Token', 'usage.events.total': '合計 {count} 件', 'usage.events.page': '{page} / {total} ページ', + 'usage.events.resetColumns': '列幅をリセット', + 'usage.events.resizeHint': 'ドラッグで列幅を調整、ダブルクリックでリセット', + 'usage.events.dragHint': 'ヘッダーの境界をドラッグして列幅を調整', 'usage.column.time': '日時', 'usage.column.model': 'モデル', + 'usage.column.provider': 'Provider', 'usage.column.source': 'ソース', + 'usage.column.key': 'API キー', 'usage.column.result': '結果', 'usage.column.latency': '合計所要時間', 'usage.column.ttft': '初回トークン遅延', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index bad90cd..4fd4477 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -113,6 +113,8 @@ export const en: Record = { 'usage.tab.analysis': 'Analysis', 'usage.tab.events': 'Request Details', 'usage.tab.pricing': 'Price Statistics', + 'usage.refresh': 'Refresh', + 'usage.filter.reset': 'Reset Filters', 'usage.filter.timeRange': 'Time range', 'usage.range.4h': 'Last 4 Hours', 'usage.range.24h': 'Last 24 Hours', @@ -131,12 +133,13 @@ export const en: Record = { 'usage.filter.allResults': 'All Results', 'usage.result.success': 'Success', 'usage.result.failed': 'Failed', + 'usage.result.canceled': 'Canceled', 'usage.filter.startTime': 'Start time', 'usage.filter.endTime': 'End time', 'usage.filter.to': 'to', 'usage.loading': 'Loading local SQLite usage history', 'usage.stat.requests': 'Total Requests', - 'usage.stat.requestMeta': 'OK {success} · Fail {failed}', + 'usage.stat.requestMeta': 'OK {success} · Fail {failed} · Canceled {canceled}', 'usage.stat.tokens': 'Total Tokens', 'usage.stat.tokenMeta': 'In {input} · Out {output}', 'usage.stat.successRate': 'Success Rate', @@ -188,9 +191,14 @@ export const en: Record = { 'usage.analysis.itemMeta': '{requests} requests · {percent}% · {tokens} tokens', 'usage.events.total': '{count} records', 'usage.events.page': 'Page {page} of {total}', + 'usage.events.resetColumns': 'Reset Widths', + 'usage.events.resizeHint': 'Drag to resize, double-click to reset', + 'usage.events.dragHint': 'Drag column header borders to resize', 'usage.column.time': 'Time', 'usage.column.model': 'Model', + 'usage.column.provider': 'Provider', 'usage.column.source': 'Source', + 'usage.column.key': 'API Key', 'usage.column.result': 'Result', 'usage.column.latency': 'Total Duration', 'usage.column.ttft': 'First Token Latency', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index f94481a..fde2413 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -111,6 +111,8 @@ export const zhCN = { 'usage.tab.analysis': '分析', 'usage.tab.events': '请求明细', 'usage.tab.pricing': '价格统计', + 'usage.refresh': '刷新数据', + 'usage.filter.reset': '重置筛选', 'usage.filter.timeRange': '时间范围', 'usage.range.4h': '最近 4 小时', 'usage.range.24h': '最近 24 小时', @@ -129,12 +131,13 @@ export const zhCN = { 'usage.filter.allResults': '全部结果', 'usage.result.success': '成功', 'usage.result.failed': '失败', + 'usage.result.canceled': '已取消', 'usage.filter.startTime': '开始时间', 'usage.filter.endTime': '结束时间', 'usage.filter.to': '至', 'usage.loading': '正在读取本地 SQLite 使用记录', 'usage.stat.requests': '请求总数', - 'usage.stat.requestMeta': '成功 {success} · 失败 {failed}', + 'usage.stat.requestMeta': '成功 {success} · 失败 {failed} · 取消 {canceled}', 'usage.stat.tokens': 'Token 总量', 'usage.stat.tokenMeta': '入 {input} · 出 {output}', 'usage.stat.successRate': '成功率', @@ -186,9 +189,14 @@ export const zhCN = { 'usage.analysis.itemMeta': '{requests} 次 · {percent}% · {tokens} Token', 'usage.events.total': '共 {count} 条记录', 'usage.events.page': '第 {page} / {total} 页', + 'usage.events.resetColumns': '重置列宽', + 'usage.events.resizeHint': '拖动调整列宽,双击重置本列', + 'usage.events.dragHint': '可拖动表头边线调整每列宽度', 'usage.column.time': '时间', 'usage.column.model': '模型', + 'usage.column.provider': 'Provider', 'usage.column.source': '来源', + 'usage.column.key': '鉴权密钥', 'usage.column.result': '结果', 'usage.column.latency': '总耗时', 'usage.column.ttft': '首字延迟', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 46b2081..f169daf 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -1878,7 +1878,7 @@ export function AgentsPage() {
- {selected === 'codex' ? ( + {hasIndependentCliAndApp ? ( <>
{error ?
{error}
: null}
- - - - + + + +
- - - - - - - {range === 'custom' ?
setCustomStart(event.currentTarget.value)} aria-label={t('usage.filter.startTime')} />{t('usage.filter.to')} setCustomEnd(event.currentTarget.value)} aria-label={t('usage.filter.endTime')} />
: null} +
+
+ + + + + + + + + + + +
+ + {hasActiveFilters ? ( + + ) : null} +
+ + {range === 'custom' ? ( +
+ setCustomStart(event.currentTarget.value)} + aria-label={t('usage.filter.startTime')} + /> + {t('usage.filter.to')} + setCustomEnd(event.currentTarget.value)} + aria-label={t('usage.filter.endTime')} + /> +
+ ) : null}
- {showInitialLoading ?
{t('usage.loading')}
: null} + {showInitialLoading ? ( +
+ + {t('usage.loading')} +
+ ) : null} {activeTab === 'overview' && overview ? : null} {activeTab === 'analysis' ? : null} {activeTab === 'events' && events ? : null} - {activeTab === 'pricing' && pricing ? loadData(true)} /> : null} + {activeTab === 'pricing' && pricing ? ( + loadData(true)} /> + ) : null} ); } @@ -414,55 +676,630 @@ export function UsageRecordsPage() { function OverviewView({ overview }: { overview: UsageOverview }) { const { t } = useI18n(); const cards = [ - { icon: Activity, label: t('usage.stat.requests'), value: compactNumber(overview.totalRequests), meta: t('usage.stat.requestMeta', { success: compactNumber(overview.successCount), failed: compactNumber(overview.failureCount) }) }, - { icon: Sparkles, label: t('usage.stat.tokens'), value: compactNumber(overview.totalTokens), meta: t('usage.stat.tokenMeta', { input: compactNumber(overview.inputTokens), output: compactNumber(overview.outputTokens) }) }, - { icon: ShieldCheck, label: t('usage.stat.successRate'), value: `${overview.successRate.toFixed(1)}%`, meta: t('usage.stat.reasoningMeta', { tokens: compactNumber(overview.reasoningTokens) }) }, - { icon: Clock3, label: t('usage.stat.tps'), value: `${overview.tps.toFixed(1)} TPS`, meta: t('usage.stat.performanceMeta', { rpm: overview.rpm.toFixed(2), latency: Math.round(overview.averageLatencyMs) }) }, - { icon: Database, label: t('usage.stat.cacheHitRate'), value: `${(overview.cacheHitRate * 100).toFixed(1)}%`, meta: t('usage.stat.cacheHitMeta', { hit: compactNumber(overview.cacheReadTokens), input: compactNumber(overview.inputTokens) }) }, - { icon: CircleDollarSign, label: t('usage.stat.estimatedCost'), value: formatUsd(overview.estimatedCost), meta: t('usage.stat.costMeta', { priced: compactNumber(overview.pricedRequests), total: compactNumber(overview.totalRequests) }) }, + { + icon: Activity, + tone: 'tone-blue', + label: t('usage.stat.requests'), + value: compactNumber(overview.totalRequests), + meta: t('usage.stat.requestMeta', { + success: compactNumber(overview.successCount), + failed: compactNumber(overview.failureCount), + canceled: compactNumber(overview.canceledCount), + }), + }, + { + icon: Sparkles, + tone: 'tone-purple', + label: t('usage.stat.tokens'), + value: compactNumber(overview.totalTokens), + meta: t('usage.stat.tokenMeta', { + input: compactNumber(overview.inputTokens), + output: compactNumber(overview.outputTokens), + }), + }, + { + icon: ShieldCheck, + tone: 'tone-green', + label: t('usage.stat.successRate'), + value: `${overview.successRate.toFixed(1)}%`, + meta: t('usage.stat.reasoningMeta', { tokens: compactNumber(overview.reasoningTokens) }), + }, + { + icon: Zap, + tone: 'tone-orange', + label: t('usage.stat.tps'), + value: `${overview.tps.toFixed(1)} TPS`, + meta: t('usage.stat.performanceMeta', { + rpm: overview.rpm.toFixed(2), + latency: Math.round(overview.averageLatencyMs), + }), + }, + { + icon: Database, + tone: 'tone-teal', + label: t('usage.stat.cacheHitRate'), + value: `${(overview.cacheHitRate * 100).toFixed(1)}%`, + meta: t('usage.stat.cacheHitMeta', { + hit: compactNumber(overview.cacheReadTokens), + input: compactNumber(overview.inputTokens), + }), + }, + { + icon: CircleDollarSign, + tone: 'tone-amber', + label: t('usage.stat.estimatedCost'), + value: formatUsd(overview.estimatedCost), + meta: t('usage.stat.costMeta', { + priced: compactNumber(overview.pricedRequests), + total: compactNumber(overview.totalRequests), + }), + }, ]; - return
-
{cards.map(({ icon: Icon, label, value, meta }) =>
{label}{value}{meta}
)}
-
{t('usage.trend.title')}{t('usage.trend.description')}
{overview.timeline.length ? : }
-
{t('usage.token.title')}{t('usage.token.description')}
-
; + + return ( +
+
+ {cards.map(({ icon: Icon, tone, label, value, meta }) => ( +
+
+ {label} +
+ +
+
+ {value} + + {meta} + +
+ ))} +
+
+
+
+ {t('usage.trend.title')} + {t('usage.trend.description')} +
+
+ {overview.timeline.length ? : } +
+
+
+
+ {t('usage.token.title')} + {t('usage.token.description')} +
+
+
+ + + + + +
+
+
+ ); } function UsageTrend({ points }: { points: TimelinePoint[] }) { const { t } = useI18n(); const recent = points.slice(-48); const max = Math.max(...recent.map((point) => point.requests), 1); - const polyline = recent.map((point, index) => `${recent.length === 1 ? 50 : index * 100 / (recent.length - 1)},${28 - point.requests * 24 / max}`).join(' '); - return
{recent[0]?.hour ?? ''}{compactNumber(recent.reduce((sum, point) => sum + point.tokens, 0))} Token{recent[recent.length - 1]?.hour ?? ''}
; + const totalTokens = recent.reduce((sum, point) => sum + point.tokens, 0); + const totalReqs = recent.reduce((sum, point) => sum + point.requests, 0); + + const pointsCoords = recent.map((point, index) => { + const x = recent.length <= 1 ? 50 : (index * 100) / (recent.length - 1); + const y = 28 - (point.requests * 24) / max; + return { x, y, point }; + }); + + const polyline = pointsCoords.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); + const areaPath = + pointsCoords.length > 0 + ? `M 0,32 L ${pointsCoords[0].x.toFixed(1)},${pointsCoords[0].y.toFixed(1)} ` + + pointsCoords.map((p) => `L ${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ') + + ` L 100,32 Z` + : ''; + + return ( +
+
+ + {compactNumber(totalReqs)} requests + + + {compactNumber(totalTokens)} tokens + +
+
+ + + + + + + + + + + {areaPath ? : null} + + +
+ {recent[0]?.hour ?? ''} + {recent.length > 2 ? {recent[Math.floor(recent.length / 2)]?.hour ?? ''} : null} + {recent[recent.length - 1]?.hour ?? ''} +
+
+
+ ); } -function TokenMetric({ label, value, total }: { label: string; value: number; total: number }) { - const percent = total ? Math.min(value * 100 / total, 100) : 0; - return
{label}{compactNumber(value)}
; +function TokenMetric({ + label, + value, + total, + tone, +}: { + label: string; + value: number; + total: number; + tone: 'input' | 'output' | 'reasoning' | 'cache-read' | 'cache-creation'; +}) { + const percent = total ? Math.min((value * 100) / total, 100) : 0; + return ( +
+
+ {label} +
+ {compactNumber(value)} + {percent.toFixed(1)}% +
+
+
+
+
+
+ ); } function AnalysisView({ analysis, overview }: { analysis: UsageAnalysis; overview: UsageOverview | null }) { const { t } = useI18n(); - const hours = (overview?.timeline ?? []).map((point) => ({ - key: point.hour, - label: point.hour, - requests: point.requests, - failures: point.failure, - tokens: point.tokens, - })).sort((left, right) => right.tokens - left.tokens); - return
; + const hours = (overview?.timeline ?? []) + .map((point) => ({ + key: point.hour, + label: point.hour, + requests: point.requests, + failures: point.failure, + tokens: point.tokens, + })) + .sort((left, right) => right.tokens - left.tokens); + return ( +
+ + + + + +
+ ); } -function CategoryPanel({ title, items, compactLabels = false }: { title: string; items: UsageCategory[]; compactLabels?: boolean }) { +function CategoryPanel({ + title, + items, + compactLabels = false, +}: { + title: string; + items: UsageCategory[]; + compactLabels?: boolean; +}) { const { t } = useI18n(); const max = Math.max(...items.map((item) => item.tokens), 1); const total = items.reduce((sum, item) => sum + item.tokens, 0); - return
{title}{t('usage.analysis.sortedByTokens')}
{items.length ?
{items.slice(0, 10).map((item) =>
{item.label}{t('usage.analysis.itemMeta', { requests: compactNumber(item.requests), percent: total ? (item.tokens * 100 / total).toFixed(1) : '0.0', tokens: compactNumber(item.tokens) })}
)}
: }
; + return ( +
+
+
+ {title} + {t('usage.analysis.sortedByTokens')} +
+
+ {items.length ? ( +
+ {items.slice(0, 10).map((item, idx) => { + const percent = total ? ((item.tokens * 100) / total).toFixed(1) : '0.0'; + return ( +
+
+
+ {idx + 1} + + {item.label} + +
+ + {compactNumber(item.requests)} requests + {percent}% + {compactNumber(item.tokens)} Token + +
+
+
+
+
+ ); + })} +
+ ) : ( + + )} +
+ ); +} + +type EventColumnKey = + | 'time' + | 'model' + | 'provider' + | 'source' + | 'key' + | 'input' + | 'output' + | 'reasoning' + | 'cache' + | 'total' + | 'result' + | 'latency' + | 'ttft'; + +type EventColumnDef = { + key: EventColumnKey; + labelKey: MessageKey; + defaultWidth: number; + minWidth: number; + align: 'left' | 'center' | 'right'; +}; + +const EVENT_COLUMNS: readonly EventColumnDef[] = [ + { key: 'time', labelKey: 'usage.column.time', defaultWidth: 135, minWidth: 90, align: 'center' }, + { key: 'model', labelKey: 'usage.column.model', defaultWidth: 175, minWidth: 110, align: 'center' }, + { key: 'provider', labelKey: 'usage.column.provider', defaultWidth: 110, minWidth: 70, align: 'center' }, + { key: 'source', labelKey: 'usage.column.source', defaultWidth: 110, minWidth: 70, align: 'center' }, + { key: 'key', labelKey: 'usage.column.key', defaultWidth: 130, minWidth: 80, align: 'center' }, + { key: 'input', labelKey: 'usage.column.input', defaultWidth: 76, minWidth: 50, align: 'center' }, + { key: 'output', labelKey: 'usage.column.output', defaultWidth: 76, minWidth: 50, align: 'center' }, + { key: 'reasoning', labelKey: 'usage.column.reasoning', defaultWidth: 76, minWidth: 50, align: 'center' }, + { key: 'cache', labelKey: 'usage.column.cache', defaultWidth: 76, minWidth: 50, align: 'center' }, + { key: 'total', labelKey: 'usage.column.total', defaultWidth: 84, minWidth: 60, align: 'center' }, + { key: 'result', labelKey: 'usage.column.result', defaultWidth: 160, minWidth: 90, align: 'center' }, + { key: 'latency', labelKey: 'usage.column.latency', defaultWidth: 88, minWidth: 65, align: 'center' }, + { key: 'ttft', labelKey: 'usage.column.ttft', defaultWidth: 88, minWidth: 65, align: 'center' }, +] as const; + +const EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v1'; + +const getInitialColumnWidths = (): Record => { + const initial: Record = {} as any; + for (const col of EVENT_COLUMNS) { + initial[col.key] = col.defaultWidth; + } + try { + const raw = localStorage.getItem(EVENT_COL_WIDTHS_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + for (const col of EVENT_COLUMNS) { + if ( + typeof parsed[col.key] === 'number' && + Number.isFinite(parsed[col.key]) && + parsed[col.key] >= col.minWidth + ) { + initial[col.key] = Math.round(parsed[col.key]); + } + } + } + } + } catch { + // fallback to defaults + } + return initial; +}; + +function UsageResultCell({ record }: { record: UsageRecord }) { + const { t } = useI18n(); + const state = record.canceled ? 'canceled' : record.failed ? 'failed' : 'success'; + const detail = [ + record.failure_status > 0 ? `HTTP ${record.failure_status}` : '', + record.failure_body.trim(), + ] + .filter(Boolean) + .join(' · '); + return ( + + + + {t(`usage.result.${state}`)} + + {detail ? {detail} : null} + + ); } function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) { const { t } = useI18n(); - return
{t('usage.events.total', { count: compactNumber(events.total) })}{t('usage.events.page', { page: events.page, total: events.totalPages })}
{events.items.length ?
{events.items.map((record) => )}
{t('usage.column.time')}{t('usage.column.model')}Provider{t('usage.column.source')}API Key{t('usage.column.input')}{t('usage.column.output')}{t('usage.column.reasoning')}{t('usage.column.cache')}{t('usage.column.total')}{t('usage.column.result')}{t('usage.column.latency')}{t('usage.column.ttft')}
{formatTime(record.timestamp)}{record.alias || record.model}{record.alias || record.reasoning_effort ? {record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort} : null}{record.provider || '—'}{record.source_display || '—'}{record.api_key_remark || t('usage.key.noRemark')}{record.api_key_display || '—'}{compactNumber(record.tokens.input_tokens)}{compactNumber(record.tokens.output_tokens)}{compactNumber(record.tokens.reasoning_tokens)}{compactNumber(record.tokens.cache_read_tokens)}{compactNumber(record.tokens.total_tokens)}{record.failed ? t('usage.result.failed') : t('usage.result.success')}{record.latency_ms} ms{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}
: }
; + const [widths, setWidths] = useState>(getInitialColumnWidths); + const [resizingCol, setResizingCol] = useState(null); + + const isCustomized = EVENT_COLUMNS.some((col) => widths[col.key] !== col.defaultWidth); + + const resetAllWidths = () => { + const defaults: Record = {} as any; + for (const col of EVENT_COLUMNS) { + defaults[col.key] = col.defaultWidth; + } + setWidths(defaults); + try { + localStorage.removeItem(EVENT_COL_WIDTHS_STORAGE_KEY); + } catch {} + }; + + const resetSingleColumn = (key: EventColumnKey, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const colDef = EVENT_COLUMNS.find((c) => c.key === key); + if (!colDef) return; + setWidths((prev) => { + const next = { ...prev, [key]: colDef.defaultWidth }; + try { + localStorage.setItem(EVENT_COL_WIDTHS_STORAGE_KEY, JSON.stringify(next)); + } catch {} + return next; + }); + }; + + const handleResizeStart = (key: EventColumnKey, e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const startX = e.clientX; + const startWidth = widths[key] ?? EVENT_COLUMNS.find((c) => c.key === key)?.defaultWidth ?? 100; + const colDef = EVENT_COLUMNS.find((c) => c.key === key); + const minWidth = colDef?.minWidth ?? 50; + + setResizingCol(key); + document.body.classList.add('table-col-resizing'); + + let currentWidth = startWidth; + + const onPointerMove = (moveEvent: PointerEvent) => { + const delta = moveEvent.clientX - startX; + const nextWidth = Math.max(minWidth, Math.round(startWidth + delta)); + currentWidth = nextWidth; + setWidths((prev) => ({ ...prev, [key]: nextWidth })); + }; + + const onPointerUp = () => { + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + document.body.classList.remove('table-col-resizing'); + setResizingCol(null); + + setWidths((prev) => { + const next = { ...prev, [key]: currentWidth }; + try { + localStorage.setItem(EVENT_COL_WIDTHS_STORAGE_KEY, JSON.stringify(next)); + } catch {} + return next; + }); + }; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + }; + + const totalTableWidth = EVENT_COLUMNS.reduce((sum, col) => sum + (widths[col.key] ?? col.defaultWidth), 0); + + return ( +
+
+
+ + {t('usage.events.total', { count: compactNumber(events.total) })} + + + {t('usage.events.dragHint')} + +
+
+ {isCustomized ? ( + + ) : null} + + {t('usage.events.page', { page: events.page, total: events.totalPages })} + +
+
+ + {events.items.length ? ( +
+ + + {EVENT_COLUMNS.map((col) => ( + + ))} + + + + {EVENT_COLUMNS.map((col) => { + const label = t(col.labelKey); + return ( + + ); + })} + + + + {events.items.map((record) => ( + + {/* time */} + + + {/* model */} + + + {/* provider */} + + + {/* source */} + + + {/* key */} + + + {/* input tokens */} + + + {/* output tokens */} + + + {/* reasoning tokens */} + + + {/* cache tokens */} + + + {/* total tokens */} + + + {/* result */} + + + {/* latency */} + + + {/* ttft */} + + + ))} + +
+
+ {label} +
+
handleResizeStart(col.key, e)} + onDoubleClick={(e) => resetSingleColumn(col.key, e)} + title={t('usage.events.resizeHint')} + /> +
+ {formatTime(record.timestamp)} + + {record.alias || record.model} + {record.alias || record.reasoning_effort ? ( + + {record.alias ? record.model : ''} + {record.alias && record.reasoning_effort ? ' · ' : ''} + {record.reasoning_effort} + + ) : null} + + {record.provider || '—'} + + {record.source_display || record.source || '—'} + + {record.api_key_remark || t('usage.key.noRemark')} + {record.api_key_display || '—'} + + {compactNumber(record.tokens.input_tokens)} + + {compactNumber(record.tokens.output_tokens)} + + {compactNumber(record.tokens.reasoning_tokens)} + 0 + ? ` / Creation: ${record.tokens.cache_creation_tokens.toLocaleString()} tokens` + : '' + }`} + > + {compactNumber(record.tokens.cache_read_tokens)} + + {compactNumber(record.tokens.total_tokens)} + + {compactNumber(record.latency_ms)} ms + + {record.ttft_ms == null ? '—' : `${compactNumber(record.ttft_ms)} ms`} +
+
+ ) : ( + + )} + +
+ + + {events.page} / {events.totalPages} + + +
+
+ ); } type PriceDraft = { @@ -474,7 +1311,14 @@ type PriceDraft = { cacheCreation: string; }; -const emptyPriceDraft = (): PriceDraft => ({ model: '', prompt: '', completion: '', cache: '', cacheRead: '', cacheCreation: '' }); +const emptyPriceDraft = (): PriceDraft => ({ + model: '', + prompt: '', + completion: '', + cache: '', + cacheRead: '', + cacheCreation: '', +}); const priceDraftFor = (model = '', price?: ModelPrice | null): PriceDraft => ({ model, prompt: price ? String(price.prompt) : '', @@ -489,9 +1333,17 @@ const parsePrice = (value: string) => { return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; }; -const priceUnit = (value: number | undefined) => Number.isFinite(value) ? `$${Number(value).toFixed(4)}` : '—'; +const priceUnit = (value: number | undefined) => (Number.isFinite(value) ? `$${Number(value).toFixed(4)}` : '—'); -function PricingView({ pricing, query, onChanged }: { pricing: UsagePricing; query: UsageQuery; onChanged: () => void | Promise }) { +function PricingView({ + pricing, + query, + onChanged, +}: { + pricing: UsagePricing; + query: UsageQuery; + onChanged: () => void | Promise; +}) { const { t } = useI18n(); const [search, setSearch] = useState(''); const [draft, setDraft] = useState(null); @@ -541,7 +1393,6 @@ function PricingView({ pricing, query, onChanged }: { pricing: UsagePricing; que const deletePrice = async (model: string) => { if (!window.confirm(t('usage.pricing.deleteConfirm', { model }))) return; - setLocalError(''); try { await invoke('delete_usage_model_price', { model }); setMessage(t('usage.pricing.deleted')); @@ -554,12 +1405,15 @@ function PricingView({ pricing, query, onChanged }: { pricing: UsagePricing; que const syncPrices = async () => { setSyncing(true); setLocalError(''); - setMessage(''); try { - const result = await invoke('sync_usage_model_prices', { query }); - setMessage(result.usedBuiltin - ? t('usage.pricing.syncFallback') - : t('usage.pricing.syncResult', { imported: result.imported, skipped: result.skipped, unmatched: result.unmatched.length })); + const result = await invoke('sync_usage_model_prices'); + setMessage( + t('usage.pricing.syncResult', { + imported: result.imported, + skipped: result.skipped, + unmatched: result.unmatched.length, + }) + ); await onChanged(); } catch (syncError) { setLocalError(String(syncError)); @@ -568,37 +1422,200 @@ function PricingView({ pricing, query, onChanged }: { pricing: UsagePricing; que } }; - return
-
-
- {formatUsd(pricing.totalCost)} - {t('usage.pricing.coverage', { priced: compactNumber(pricing.pricedRequests), total: compactNumber(pricing.totalRequests), saved: compactNumber(pricing.savedPrices) })} + return ( +
+
+
+ {formatUsd(pricing.totalCost)} + + {t('usage.pricing.coverage', { + priced: compactNumber(pricing.pricedRequests), + total: compactNumber(pricing.totalRequests), + saved: compactNumber(pricing.savedPrices), + })} + +
+
+ setSearch(event.currentTarget.value)} + placeholder={t('usage.pricing.search')} + aria-label={t('usage.pricing.search')} + /> + + +
-
- setSearch(event.currentTarget.value)} placeholder={t('usage.pricing.search')} aria-label={t('usage.pricing.search')} /> - - -
-
- {localError ?
{localError}
: null} - {message ?
{message}
: null} + {localError ?
{localError}
: null} + {message ?
{message}
: null} - {draft ?
- - - - - - -
-
: null} + {draft ? ( +
+ + + + + + +
+ + +
+
+ ) : null} - {visibleRows.length ?
{visibleRows.map((row) => )}
{t('usage.pricing.model')}{t('usage.pricing.calls')}Token{t('usage.pricing.cost')}{t('usage.pricing.prompt')}{t('usage.pricing.completion')}{t('usage.pricing.cacheRead')}{t('usage.pricing.cacheCreation')}{t('usage.pricing.actions')}
{row.model}{compactNumber(row.requests)}{compactNumber(row.totalTokens)}{row.price ? formatUsd(row.estimatedCost) : '—'}{row.price ? priceUnit(row.price.prompt) : '—'}{row.price ? priceUnit(row.price.completion) : '—'}{row.price ? priceUnit(row.price.cacheReadConfigured || row.price.cacheRead > 0 ? row.price.cacheRead : row.price.cache) : '—'}{row.price ? priceUnit(row.price.cacheCreationConfigured || row.price.cacheCreation > 0 ? row.price.cacheCreation : row.price.prompt) : '—'}
{row.price?.source === 'manual' ? : null}
: } -
; + {visibleRows.length ? ( +
+ + + + + + + + + + + + + + + + {visibleRows.map((row) => ( + + + + + + + + + + + + ))} + +
{t('usage.pricing.model')}{t('usage.pricing.calls')}Token{t('usage.pricing.cost')}{t('usage.pricing.prompt')}{t('usage.pricing.completion')}{t('usage.pricing.cacheRead')}{t('usage.pricing.cacheCreation')}{t('usage.pricing.actions')}
+ {row.model} + {compactNumber(row.requests)}{compactNumber(row.totalTokens)} + {row.price ? formatUsd(row.estimatedCost) : '—'} + {row.price ? priceUnit(row.price.prompt) : '—'}{row.price ? priceUnit(row.price.completion) : '—'} + {row.price + ? priceUnit( + row.price.cacheReadConfigured || row.price.cacheRead > 0 + ? row.price.cacheRead + : row.price.cache + ) + : '—'} + + {row.price + ? priceUnit( + row.price.cacheCreationConfigured || row.price.cacheCreation > 0 + ? row.price.cacheCreation + : row.price.prompt + ) + : '—'} + +
+ + {row.price?.source === 'manual' ? ( + + ) : null} +
+
+
+ ) : ( + + )} + + ); } function UsageEmpty() { const { t } = useI18n(); - return
{t('usage.empty')}
; + return ( +
+ + {t('usage.empty')} +
+ ); } diff --git a/src/styles.css b/src/styles.css index 7516a43..f52c001 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6391,70 +6391,120 @@ textarea:focus-visible { .usage-records-page { display: grid; - gap: 14px; + gap: 16px; align-content: start; } .usage-records-header { - min-height: 54px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 56px; + flex-wrap: wrap; +} + +.usage-header-left { + display: grid; + gap: 2px; +} + +.usage-header-tag { + font-size: var(--font-size-caption); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--theme-8a847c); +} + +.usage-header-right { + display: flex; + align-items: center; + gap: 10px; +} + +.usage-refresh-btn { + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--theme-e3e1db); + background: var(--theme-fffdf8); + color: var(--theme-5f5a53); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s ease; +} + +.usage-refresh-btn:hover:not(:disabled) { + background: var(--theme-f4f1ea); + border-color: var(--theme-d5d2cb); + color: var(--theme-2d2a26); +} + +.usage-refresh-btn:disabled { + opacity: 0.6; + cursor: not-allowed; } .usage-collector-state { display: flex; align-items: center; gap: 10px; - min-width: 164px; - padding: 8px 11px; + min-width: 170px; + padding: 6px 12px; border: 1px solid var(--theme-e3e1db); border-radius: 8px; background: var(--theme-fffdf8); } .usage-collector-state .status-dot { - width: 9px; - height: 9px; - box-shadow: 0 0 0 4px rgba(155, 149, 140, 0.12); + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--theme-8a847c); + flex-shrink: 0; + box-shadow: 0 0 0 3px rgba(155, 149, 140, 0.15); } .usage-collector-state.success .status-dot { - background: var(--theme-2f6b3f); - box-shadow: 0 0 0 4px rgba(47, 107, 63, 0.12); + background: #10b981; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2); } .usage-collector-state.error .status-dot { - background: var(--theme-9f2d20); - box-shadow: 0 0 0 4px rgba(159, 45, 32, 0.12); + background: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2); } .usage-collector-state > div { display: grid; - gap: 2px; + gap: 1px; } .usage-collector-state strong { color: var(--theme-2d2a26); - font-size: var(--font-size-meta); + font-size: var(--font-size-caption); + font-weight: 600; + line-height: 1.2; } .usage-collector-state span { - margin: 0; - overflow: visible; color: var(--theme-8a847c); - font-size: var(--font-size-caption); - font-weight: 400; - text-transform: none; - white-space: normal; + font-size: 11px; + line-height: 1.2; } +/* Tab Switcher */ .usage-tabs { - display: inline-grid; - grid-auto-flow: column; - justify-self: start; - gap: 3px; - padding: 3px; - border: 1px solid var(--theme-d5d2cb); + display: inline-flex; + gap: 4px; + padding: 4px; + border: 1px solid var(--theme-e3e1db); border-radius: 8px; background: var(--theme-eeece6); + width: fit-content; } .usage-tabs button { @@ -6462,140 +6512,283 @@ textarea:focus-visible { align-items: center; justify-content: center; gap: 6px; - min-width: 104px; - height: 34px; - padding: 0 12px; + min-width: 96px; + height: 32px; + padding: 0 14px; border: 0; border-radius: 6px; background: transparent; color: var(--theme-7a746c); font-size: var(--font-size-meta); - font-weight: 700; + font-weight: 600; + cursor: pointer; + transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); +} + +.usage-tabs button:hover:not(.active) { + color: var(--theme-2d2a26); + background: rgba(255, 255, 255, 0.35); } .usage-tabs button.active { background: var(--theme-fffdf8); color: var(--theme-2d2a26); - box-shadow: 0 1px 4px rgba(45, 42, 38, 0.14); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); } +/* Filter Toolbar */ .usage-filter-panel { display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); - gap: 8px; - padding: 10px; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--theme-e3e1db); + border-radius: 9px; + background: var(--theme-fffdf8); } -.usage-filter-panel select, -.usage-filter-panel input { +.usage-filter-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.usage-filter-group { + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; + flex: 1; +} + +.usage-filter-item { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 120px; + flex: 1 1 130px; +} + +.usage-filter-label { + display: flex; + align-items: center; + gap: 4px; + color: var(--theme-8a847c); + font-size: 11px; + font-weight: 600; + line-height: 1; +} + +.usage-filter-item select, +.usage-filter-item input { min-width: 0; - height: 36px; + height: 34px; padding: 0 9px; border: 1px solid var(--theme-d5d2cb); border-radius: 6px; background: var(--theme-fff); color: var(--theme-2d2a26); font-size: var(--font-size-meta); + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.usage-filter-item select:focus, +.usage-filter-item input:focus { + border-color: var(--theme-3f6f98); + box-shadow: 0 0 0 2px rgba(63, 111, 152, 0.15); +} + +.usage-filter-reset-btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 34px; + padding: 0 12px; + border-radius: 6px; + font-size: var(--font-size-caption); + font-weight: 600; + border: 1px dashed var(--theme-d5d2cb); + color: var(--theme-8a847c); + background: transparent; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s ease; +} + +.usage-filter-reset-btn:hover { + background: var(--theme-fff1ee); + color: var(--theme-9f2d20); + border-color: var(--theme-efc9c2); } .usage-custom-range { - display: grid; - grid-column: 1 / -1; - grid-template-columns: minmax(170px, 1fr) auto minmax(170px, 1fr); + display: flex; align-items: center; gap: 8px; + padding-top: 6px; + border-top: 1px dashed var(--theme-eeeae2); color: var(--theme-8a847c); - font-size: var(--font-size-meta); + font-size: var(--font-size-caption); } +.usage-custom-range input { + height: 32px; + padding: 0 8px; + border: 1px solid var(--theme-d5d2cb); + border-radius: 6px; + background: var(--theme-fff); + color: var(--theme-2d2a26); + font-size: var(--font-size-caption); +} + +/* Loading & Empty States */ .usage-initial-loading, .usage-empty { display: flex; + flex-direction: column; align-items: center; justify-content: center; - gap: 8px; - min-height: 120px; + gap: 10px; + min-height: 160px; + padding: 30px; color: var(--theme-8a847c); font-size: var(--font-size-meta); } +.usage-empty svg, +.usage-initial-loading svg { + color: var(--theme-9a958d); + opacity: 0.6; +} + +/* Overview Layout */ .usage-overview-layout { display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 10px; + grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr); + gap: 14px; } .usage-stat-grid { - display: grid; grid-column: 1 / -1; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 10px; -} - -.usage-trend-panel { - grid-column: 1 / 5; -} - -.usage-health-panel { - grid-column: 5 / 7; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; } .usage-stat-card { display: grid; - min-width: 0; gap: 8px; - padding: 14px; + padding: 14px 16px; + border: 1px solid var(--theme-e3e1db); + border-radius: 9px; + background: var(--theme-fffdf8); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; } -.usage-stat-card > span { +.usage-stat-card:hover { + border-color: var(--theme-d5d2cb); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.usage-stat-card-top { display: flex; align-items: center; - gap: 7px; + justify-content: space-between; + gap: 8px; +} + +.usage-stat-card-label { color: var(--theme-7a746c); - font-size: var(--font-size-meta); -} - -.usage-stat-card > strong { - overflow: hidden; - color: var(--theme-2d2a26); - font-size: var(--font-size-display); - text-overflow: ellipsis; - white-space: nowrap; -} - -.usage-stat-card > small { - overflow: hidden; - min-width: 0; - color: var(--theme-8a847c); font-size: var(--font-size-caption); - line-height: var(--line-height-body); + font-weight: 600; +} + +.usage-stat-card-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; +} + +.usage-stat-card.tone-blue .usage-stat-card-icon { + background: rgba(59, 130, 246, 0.12); + color: #2563eb; +} + +.usage-stat-card.tone-purple .usage-stat-card-icon { + background: rgba(139, 92, 246, 0.12); + color: #7c3aed; +} + +.usage-stat-card.tone-green .usage-stat-card-icon { + background: rgba(16, 185, 129, 0.12); + color: #059669; +} + +.usage-stat-card.tone-orange .usage-stat-card-icon { + background: rgba(249, 115, 22, 0.12); + color: #ea580c; +} + +.usage-stat-card.tone-teal .usage-stat-card-icon { + background: rgba(20, 184, 166, 0.12); + color: #0d9488; +} + +.usage-stat-card.tone-amber .usage-stat-card-icon { + background: rgba(245, 158, 11, 0.12); + color: #d97706; +} + +.usage-stat-card-value { + color: var(--theme-2d2a26); + font-size: 22px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.15; + letter-spacing: -0.01em; +} + +.usage-stat-card-meta { + color: var(--theme-8a847c); + font-size: 11px; + line-height: 1.3; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Trend Panel */ .usage-trend-panel, .usage-health-panel, .usage-category-panel, .usage-events-panel { - min-width: 0; + padding: 16px; + border: 1px solid var(--theme-e3e1db); + border-radius: 9px; + background: var(--theme-fffdf8); } .usage-section-heading { display: flex; align-items: center; justify-content: space-between; - min-height: 36px; - margin-bottom: 10px; + margin-bottom: 12px; } .usage-section-heading > div { display: grid; - gap: 3px; + gap: 2px; } .usage-section-heading strong { color: var(--theme-2d2a26); font-size: var(--font-size-body); + font-weight: 700; } .usage-section-heading span { @@ -6603,6 +6796,34 @@ textarea:focus-visible { font-size: var(--font-size-caption); } +.usage-trend-wrapper { + display: grid; + gap: 10px; +} + +.usage-trend-header-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.usage-trend-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 6px; + background: var(--theme-f4f1ea); + color: var(--theme-7a746c); + font-size: 11px; +} + +.usage-trend-chip strong { + color: var(--theme-2d2a26); + font-weight: 600; +} + .usage-trend { display: grid; gap: 8px; @@ -6613,12 +6834,11 @@ textarea:focus-visible { height: 180px; overflow: visible; border-bottom: 1px solid var(--theme-e3e1db); - background: repeating-linear-gradient(to bottom, transparent 0, transparent 43px, var(--theme-eeeae2) 44px); } .usage-trend polyline { - stroke: var(--theme-3f6f98); - stroke-width: 2; + stroke: #3b82f6; + stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; } @@ -6627,247 +6847,443 @@ textarea:focus-visible { display: flex; align-items: center; justify-content: space-between; - gap: 10px; color: var(--theme-8a847c); - font-size: var(--font-size-caption); + font-size: 11px; + font-variant-numeric: tabular-nums; } -.usage-trend-labels strong { - color: var(--theme-5f5a53); - font-size: var(--font-size-caption); -} - -.usage-token-breakdown, -.usage-category-list { +/* Token Breakdown Panel */ +.usage-token-breakdown { display: grid; - width: 100%; - min-width: 0; gap: 12px; } -.usage-token-breakdown > div, -.usage-category-list > div { +.usage-token-row { display: grid; - grid-template-columns: minmax(0, 1fr); - min-width: 0; gap: 6px; } -.usage-token-breakdown span, -.usage-category-list span { +.usage-token-info { display: flex; align-items: center; justify-content: space-between; - width: 100%; - min-width: 0; + font-size: var(--font-size-caption); +} + +.usage-token-name { + color: var(--theme-2d2a26); + font-weight: 600; +} + +.usage-token-vals { + display: flex; + align-items: center; gap: 8px; } -.usage-token-breakdown strong, -.usage-category-list strong { - flex: 1 1 auto; - min-width: 0; +.usage-token-count { + color: var(--theme-7a746c); + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +.usage-token-pct { + display: inline-block; + min-width: 42px; + text-align: right; + color: var(--theme-8a847c); + font-variant-numeric: tabular-nums; + font-size: 11px; +} + +.usage-token-bar-track { + width: 100%; + height: 6px; + background: var(--theme-eeece6); + border-radius: 999px; + overflow: hidden; +} + +.usage-token-bar-fill { + height: 100%; + border-radius: 999px; + transition: width 0.3s ease; +} + +.usage-token-row.tone-input .usage-token-bar-fill { + background: #3b82f6; +} + +.usage-token-row.tone-output .usage-token-bar-fill { + background: #10b981; +} + +.usage-token-row.tone-reasoning .usage-token-bar-fill { + background: #8b5cf6; +} + +.usage-token-row.tone-cache-read .usage-token-bar-fill { + background: #f59e0b; +} + +.usage-token-row.tone-cache-creation .usage-token-bar-fill { + background: #ec4899; +} + +/* Analysis Tab */ +.usage-analysis-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 14px; +} + +.usage-category-list { + display: grid; + gap: 10px; +} + +.usage-category-row { + display: grid; + gap: 5px; +} + +.usage-category-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.usage-category-label-wrap { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; +} + +.usage-rank-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + background: var(--theme-eeece6); + color: var(--theme-7a746c); + font-size: 10px; + font-weight: 700; + flex-shrink: 0; +} + +.usage-rank-badge.top { + background: rgba(59, 130, 246, 0.15); + color: #2563eb; +} + +.usage-category-name { + color: var(--theme-2d2a26); + font-size: var(--font-size-caption); + font-weight: 600; overflow: hidden; - color: var(--theme-5f5a53); - font-size: var(--font-size-meta); text-overflow: ellipsis; white-space: nowrap; } -.usage-token-breakdown small, -.usage-category-list small { - flex: none; +.usage-category-meta { + display: flex; + align-items: center; + gap: 6px; color: var(--theme-8a847c); - font-size: var(--font-size-caption); + font-size: 11px; + font-variant-numeric: tabular-nums; + flex-shrink: 0; } -.usage-token-breakdown i, -.usage-category-list i { - display: block; +.usage-category-pct { + padding: 1px 4px; + border-radius: 3px; + background: var(--theme-f4f1ea); + font-weight: 600; +} + +.usage-category-meta strong { + color: var(--theme-5f5a53); + font-weight: 600; +} + +.usage-category-track { width: 100%; - min-width: 0; - max-width: 100%; - overflow: hidden; - height: 5px; - border-radius: 999px; + height: 4px; background: var(--theme-eeece6); + border-radius: 999px; + overflow: hidden; } -.usage-token-breakdown b, -.usage-category-list b { - display: block; +.usage-category-fill { height: 100%; - border-radius: inherit; - background: var(--theme-6684a1); + background: var(--theme-3f6f98); + border-radius: 999px; + transition: width 0.3s ease; } -.usage-analysis-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); - gap: 12px; -} - -.usage-category-panel { - min-height: 290px; -} - -.usage-category-panel.compact-labels .usage-category-list strong { - flex: 0 1 auto; - max-width: min(46%, 160px); -} - -.usage-events-panel { - display: grid; - gap: 10px; - padding: 12px; -} - -.usage-events-summary, -.usage-pagination { +/* Events Tab (请求明细) */ +.usage-events-summary { display: flex; align-items: center; justify-content: space-between; - gap: 10px; + gap: 12px; color: var(--theme-8a847c); font-size: var(--font-size-caption); + flex-wrap: wrap; +} + +.usage-events-summary-left { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.usage-events-count-badge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 6px; + background: var(--theme-f4f1ea); + color: var(--theme-2d2a26); + font-weight: 600; + font-size: var(--font-size-caption); +} + +.usage-events-summary-right { + display: flex; + align-items: center; + gap: 10px; +} + +.usage-events-page-indicator { + font-variant-numeric: tabular-nums; + color: var(--theme-7a746c); + font-size: var(--font-size-caption); +} + +.usage-col-hint { + font-size: 11px; + color: var(--theme-9a958d); + opacity: 0.85; +} + +.usage-col-reset-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + font-size: 11px; + font-weight: 600; + background: var(--theme-f4f1ea); + border: 1px solid var(--theme-e3e1db); + border-radius: 5px; + color: var(--theme-7a746c); + cursor: pointer; + line-height: 1; + transition: all 0.15s ease; +} + +.usage-col-reset-btn:hover { + background: var(--theme-e8e5dc); + color: var(--theme-2d2a26); + border-color: var(--theme-d5d2cb); } .usage-table-wrap { overflow: auto; - min-height: 360px; + min-height: 380px; + max-height: 620px; border: 1px solid var(--theme-e3e1db); - border-radius: 7px; + border-radius: 8px; + background: var(--theme-fffdf8); } .usage-events-table { - width: 100%; - min-width: 1140px; border-collapse: collapse; table-layout: fixed; font-size: var(--font-size-caption); + width: 100%; } .usage-events-table th, .usage-events-table td { height: 42px; - padding: 7px 9px; + padding: 6px 10px; border-bottom: 1px solid var(--theme-eeeae2); color: var(--theme-5f5a53); - text-align: right; white-space: nowrap; + vertical-align: middle; + box-sizing: border-box; + text-align: center; +} + +.usage-events-table tr:nth-child(even) td { + background-color: var(--theme-faf8f4); +} + +.usage-events-table tr:hover td { + background-color: var(--theme-f2efe8) !important; } .usage-events-table th { position: sticky; - z-index: 1; + z-index: 2; top: 0; background: var(--theme-f4f1ea); color: var(--theme-7a746c); font-weight: 700; + padding: 0; + user-select: none; + border-bottom: 1px solid var(--theme-e3e1db); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); } -.usage-events-table th:first-child, -.usage-events-table th:nth-child(2), -.usage-events-table th:nth-child(3), -.usage-events-table td:first-child, -.usage-events-table td:nth-child(2), -.usage-events-table td:nth-child(3) { +.usage-th-content { + display: flex; + align-items: center; + height: 42px; + padding: 6px 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.usage-events-table th.align-left .usage-th-content, +.usage-events-table td.align-left { text-align: left; + justify-content: flex-start; } -.usage-events-table th:first-child { - width: 120px; -} - -.usage-events-table th:nth-child(2) { - width: 120px; -} - -.usage-events-table th:nth-child(3) { - width: 120px; -} - -.usage-events-table th:nth-child(4) { - width: 120px; -} - -.usage-events-table th:nth-child(5) { - width: 78px; -} - -.usage-events-table th:nth-child(n + 6):nth-child(-n + 10) { - width: 64px; +.usage-events-table th.align-center .usage-th-content, +.usage-events-table td.align-center { text-align: center; + justify-content: center; } -.usage-events-table td:nth-child(n + 6):nth-child(-n + 10) { - text-align: center; +.usage-events-table th.align-right .usage-th-content, +.usage-events-table td.align-right { + text-align: right; + justify-content: flex-end; } -.usage-events-table th:nth-child(11) { - width: 72px; +.usage-col-resizer { + position: absolute; + top: 0; + right: -3px; + bottom: 0; + width: 7px; + cursor: col-resize; + z-index: 3; + touch-action: none; + display: flex; + justify-content: center; + align-items: center; } -.usage-events-table th:nth-child(12) { - width: 85px; +.usage-col-resizer::after { + content: ''; + display: block; + width: 2px; + height: 45%; + border-radius: 1px; + background-color: transparent; + transition: background-color 0.15s ease, height 0.15s ease; } -.usage-events-table th:nth-child(13) { - width: 70px; +.usage-events-table th:hover .usage-col-resizer::after { + background-color: var(--theme-d5d2cb); } -.usage-events-table th:nth-child(-n + 5), -.usage-events-table td:nth-child(-n + 5) { - padding-left: 12px; - padding-right: 12px; - text-align: left; +.usage-col-resizer:hover::after, +.usage-col-resizer.active::after { + background-color: var(--theme-3f6f98) !important; + width: 2px; + height: 100%; } -.usage-events-table th:nth-child(5), -.usage-events-table td:nth-child(5) { - padding-right: 2px; +body.table-col-resizing, +body.table-col-resizing * { + cursor: col-resize !important; + user-select: none !important; } -.usage-events-table th:nth-child(3), -.usage-events-table td:nth-child(3), -.usage-events-table th:nth-child(4), -.usage-events-table td:nth-child(4) { +.usage-events-table td.usage-td-time { + font-variant-numeric: tabular-nums; + color: var(--theme-7a746c); +} + +.usage-events-table td.usage-td-token, +.usage-events-table td.usage-td-latency, +.usage-events-table td.usage-td-ttft { + font-variant-numeric: tabular-nums; +} + +.usage-events-table td.usage-td-provider, +.usage-events-table td.usage-td-source { overflow: hidden; text-overflow: ellipsis; } -.usage-events-table th:nth-child(6), -.usage-events-table td:nth-child(6) { - padding-left: 2px; - text-align: left; +.usage-tag-pill { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + background: var(--theme-eeece6); + color: var(--theme-5f5a53); + font-size: 11px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .usage-events-table .usage-stacked-cell { - max-width: 180px; vertical-align: middle; + overflow: hidden; } .usage-events-table .usage-stacked-cell > strong, .usage-events-table .usage-stacked-cell > small { display: block; -} - -.usage-events-table td strong, -.usage-events-table td small { overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; +} + +.usage-events-table td strong { + font-weight: 600; + color: var(--theme-2d2a26); } .usage-events-table td small { color: var(--theme-8a847c); - font-size: var(--font-size-caption); + font-size: 11px; + line-height: 1.3; } .usage-result { display: inline-flex; - padding: 3px 7px; + align-items: center; + gap: 5px; + padding: 2px 7px; border-radius: 999px; - font-weight: 700; + font-weight: 600; + font-size: 11px; + line-height: 1.3; +} + +.usage-result-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; } .usage-result.success { @@ -6880,38 +7296,78 @@ textarea:focus-visible { color: var(--theme-9f2d20); } +.usage-result.canceled { + background: var(--theme-f4f1ea); + color: var(--theme-7a746c); +} + +.usage-events-table .usage-result-cell { + overflow: hidden; + text-align: center; +} + +.usage-result-cell > small { + display: block; + margin-top: 2px; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--theme-8a847c); + font-size: 11px; +} + .usage-pagination { + display: flex; + align-items: center; justify-content: flex-end; + gap: 8px; + color: var(--theme-8a847c); + font-size: var(--font-size-caption); +} + +.usage-pagination-info { + padding: 0 6px; + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--theme-7a746c); } .usage-pagination button { - min-width: 78px; - min-height: 32px; + min-width: 72px; + min-height: 30px; + padding: 0 10px; + border-radius: 6px; + font-size: var(--font-size-caption); } +/* Pricing Tab */ .usage-pricing-panel { display: grid; min-width: 0; - gap: 12px; - padding: 12px; + gap: 14px; + padding: 16px; + border: 1px solid var(--theme-e3e1db); + border-radius: 9px; + background: var(--theme-fffdf8); } .usage-pricing-toolbar { display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 14px; + flex-wrap: wrap; } .usage-pricing-summary { display: grid; - min-width: 0; - gap: 3px; + gap: 2px; } .usage-pricing-summary strong { color: var(--theme-2d2a26); - font-size: var(--font-size-metric-compact); + font-size: 20px; + font-weight: 700; + font-variant-numeric: tabular-nums; } .usage-pricing-summary span { @@ -6922,13 +7378,12 @@ textarea:focus-visible { .usage-pricing-actions { display: flex; align-items: center; - justify-content: flex-end; gap: 8px; } .usage-pricing-actions input { - width: 220px; - height: 38px; + min-width: 180px; + height: 34px; padding: 0 10px; border: 1px solid var(--theme-d5d2cb); border-radius: 6px; @@ -6939,35 +7394,34 @@ textarea:focus-visible { .usage-pricing-actions button, .usage-price-editor-actions button { - gap: 6px; + height: 34px; } .usage-price-editor { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; - padding: 12px; - border: 1px solid var(--theme-ddd8ce); + padding: 14px; + border: 1px solid var(--theme-e3e1db); border-radius: 8px; background: var(--theme-f8f5ee); } .usage-price-editor label { display: grid; - min-width: 0; - gap: 5px; + gap: 4px; } .usage-price-editor label > span { - color: var(--theme-6f6961); - font-size: var(--font-size-caption); - font-weight: 700; + color: var(--theme-7a746c); + font-size: 11px; + font-weight: 600; } .usage-price-editor input { min-width: 0; - height: 36px; - padding: 0 9px; + height: 34px; + padding: 0 8px; border: 1px solid var(--theme-d5d2cb); border-radius: 6px; background: var(--theme-fff); @@ -6983,7 +7437,7 @@ textarea:focus-visible { } .usage-pricing-table-wrap { - min-height: 390px; + min-height: 380px; } .usage-pricing-table { @@ -6995,11 +7449,11 @@ textarea:focus-visible { .usage-pricing-table th, .usage-pricing-table td { - height: 44px; - padding: 7px 9px; + height: 42px; + padding: 7px 10px; border-bottom: 1px solid var(--theme-eeeae2); color: var(--theme-5f5a53); - text-align: right; + text-align: center; white-space: nowrap; } @@ -7010,16 +7464,17 @@ textarea:focus-visible { background: var(--theme-f4f1ea); color: var(--theme-7a746c); font-weight: 700; + text-align: center; } .usage-pricing-table th:first-child, .usage-pricing-table td:first-child { - text-align: left; + text-align: center; } .usage-price-row-actions { display: flex; - justify-content: flex-end; + justify-content: center; gap: 5px; } @@ -7831,22 +8286,13 @@ textarea:focus-visible { grid-template-columns: 200px minmax(0, 1fr); } - .usage-filter-panel { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - .usage-overview-layout { - grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.6fr); + grid-template-columns: 1fr; gap: 12px; } - .usage-trend-panel, - .usage-health-panel { - grid-column: auto; - } - .usage-stat-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } .control-panel, @@ -7986,10 +8432,6 @@ textarea:focus-visible { grid-template-columns: 1fr; } - .usage-filter-panel { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .usage-pricing-toolbar { align-items: stretch; flex-direction: column; @@ -8246,21 +8688,23 @@ textarea:focus-visible { .usage-tabs { width: 100%; - grid-auto-columns: 1fr; + display: flex; + flex-wrap: wrap; } .usage-tabs button { + flex: 1 1 45%; min-width: 0; padding: 0 7px; } - .usage-filter-panel, .usage-stat-grid { grid-template-columns: 1fr; } .usage-custom-range { - grid-template-columns: 1fr; + flex-direction: column; + align-items: stretch; } .provider-category-panel {