From 72d892d5e353e2af3a3d3240f8943896a0179a99 Mon Sep 17 00:00:00 2001 From: zyycn Date: Wed, 15 Jul 2026 23:52:35 +0800 Subject: [PATCH] feat(dashboard): improve request health visibility Classify final request outcomes into success, failure, cancellation, and caller-error buckets so availability reflects only eligible requests. Expose structured 15-minute health points with focused coverage. Redesign the health timeline, trend, wire-profile, and diagnostic surfaces with responsive shared interactions, and standardize configuration schema version 1. Co-authored-by: Codex --- backend/src/api/admin/dashboard_routes.rs | 20 +- backend/src/bootstrap/config.rs | 4 +- backend/src/telemetry/dashboard.rs | 159 +++++-- backend/src/telemetry/usage/insights.rs | 100 ++++- backend/src/telemetry/usage/query.rs | 15 +- backend/tests/api/admin/dashboard_routes.rs | 15 +- backend/tests/bootstrap/config/mod.rs | 2 +- backend/tests/telemetry/dashboard.rs | 65 +++ backend/tests/telemetry/mod.rs | 1 + backend/tests/telemetry/usage/insights.rs | 108 +++++ backend/tests/telemetry/usage/mod.rs | 1 + deploy/config.example.yaml | 2 +- frontend/src/components/base/BasePopover.vue | 51 ++- .../src/components/base/BaseTable/columns.ts | 7 +- .../components/HealthTimelinePointPopover.vue | 168 ++++++++ .../components/RequestHealthTimelineCard.vue | 304 +++++++++++-- .../dashboard/components/RequestTrendCard.vue | 404 ++++++++---------- .../dashboard/components/WireProfileCard.vue | 60 +-- .../dashboard/composables/useDashboard.ts | 9 +- frontend/src/views/dashboard/constants.ts | 86 ++++ .../views/usage/components/OpsErrorPanel.vue | 2 +- .../usage/components/UsageDiagnosticCard.vue | 34 +- .../usage/components/UsageInsightsGrid.vue | 4 +- .../usage/composables/useUsageRecordsTable.ts | 8 - frontend/src/views/usage/index.vue | 2 - 25 files changed, 1227 insertions(+), 404 deletions(-) create mode 100644 backend/tests/telemetry/dashboard.rs create mode 100644 backend/tests/telemetry/usage/insights.rs create mode 100644 frontend/src/views/dashboard/components/HealthTimelinePointPopover.vue create mode 100644 frontend/src/views/dashboard/constants.ts diff --git a/backend/src/api/admin/dashboard_routes.rs b/backend/src/api/admin/dashboard_routes.rs index 18d5615ca..5c2dcf14a 100644 --- a/backend/src/api/admin/dashboard_routes.rs +++ b/backend/src/api/admin/dashboard_routes.rs @@ -38,6 +38,7 @@ use crate::{ dashboard_health_timeline_data, dashboard_trend_data, }, usage::{ + insights::RequestHealthTimeBucket, query::{UsageQueryFilter, UsageRecordAccountUsage}, types::{UsageRecord, metadata_string}, }, @@ -230,7 +231,10 @@ pub(crate) async fn dashboard_summary( .await .map_err(|error| dashboard_data_error("recent usage records", &error))?; - let time_buckets = dashboard_time_buckets(&state, now).await?; + let (time_buckets, health_buckets) = tokio::try_join!( + dashboard_time_buckets(&state, now), + dashboard_health_buckets(&state, now), + )?; let account_ids = accounts .iter() .map(|account| account.id.clone()) @@ -265,7 +269,7 @@ pub(crate) async fn dashboard_summary( &retained_usage, ), trend, - health_timeline: dashboard_health_timeline_data(&time_buckets), + health_timeline: dashboard_health_timeline_data(&health_buckets), account_usage: account_usage_data( &accounts, &account_usage_records, @@ -321,6 +325,18 @@ async fn dashboard_time_buckets( .map_err(|error| dashboard_data_error("time buckets", &error)) } +async fn dashboard_health_buckets( + state: &AppState, + now: DateTime, +) -> Result, AdminError> { + state + .services + .usage_records + .health_timeline(china_day_start(now), now) + .await + .map_err(|error| dashboard_data_error("health timeline", &error)) +} + fn dashboard_account_counts(accounts: &[Account]) -> DashboardAccountCounts { DashboardAccountCounts { total: accounts.len() as u64, diff --git a/backend/src/bootstrap/config.rs b/backend/src/bootstrap/config.rs index b4f652972..907be2cb0 100644 --- a/backend/src/bootstrap/config.rs +++ b/backend/src/bootstrap/config.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, de::IgnoredAny}; use tracing_subscriber::EnvFilter; use url::Url; -const CONFIG_SCHEMA_VERSION: u32 = 2; +const CONFIG_SCHEMA_VERSION: u32 = 1; const CONFIG_RELATIVE_PATH: &str = "deploy/config.yaml"; const SERVER_HOST_ENV: &str = "CPR_SERVER_HOST"; const SERVER_PORT_ENV: &str = "CPR_SERVER_PORT"; @@ -683,7 +683,7 @@ pub enum ConfigError { path: PathBuf, }, /// 配置格式版本不受支持。 - #[error("x-cpr.schema_version must be 2")] + #[error("x-cpr.schema_version must be 1")] UnsupportedSchemaVersion, /// 字段值非法。 #[error("configuration field `{0}` is invalid")] diff --git a/backend/src/telemetry/dashboard.rs b/backend/src/telemetry/dashboard.rs index 50c7c7e61..a9bf386f7 100644 --- a/backend/src/telemetry/dashboard.rs +++ b/backend/src/telemetry/dashboard.rs @@ -12,10 +12,13 @@ use crate::infra::{ }; use super::account_usage::query::{AccountUsageTimeBucket, RetainedUsageSummary}; +use super::usage::insights::RequestHealthTimeBucket; const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15; const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4; -const HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD: u64 = 3; +const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10; +const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3; +const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0; const TREND_SLOT_MINUTES: i64 = 15; #[derive(Debug, Clone, Copy, Default)] @@ -40,7 +43,35 @@ pub struct DashboardHealthTimelineData { title: String, description: String, reliability_display: String, - points: String, + status: DashboardHealthTimelineStatus, + success_requests: u64, + failed_requests: u64, + cancelled_requests: u64, + caller_error_requests: u64, + points: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardHealthTimelinePointData { + time: String, + status: DashboardHealthTimelineStatus, + reliability_display: String, + success_requests: u64, + failed_requests: u64, + cancelled_requests: u64, + caller_error_requests: u64, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +enum DashboardHealthTimelineStatus { + Future, + NoData, + Unavailable, + Unstable, + LowSample, + Stable, } #[derive(Debug, Serialize)] @@ -148,6 +179,14 @@ struct UsageWindow { min_first_token_bucket_latency: u64, } +#[derive(Debug, Clone, Copy, Default)] +struct HealthWindow { + success_requests: u64, + failed_requests: u64, + cancelled_requests: u64, + caller_error_requests: u64, +} + impl UsageWindow { fn tokens(self) -> u64 { self.input_tokens + self.output_tokens @@ -320,13 +359,13 @@ pub fn dashboard_trend_data_at( } pub fn dashboard_health_timeline_data( - buckets: &[AccountUsageTimeBucket], + buckets: &[RequestHealthTimeBucket], ) -> DashboardHealthTimelineData { dashboard_health_timeline_data_at(buckets, Utc::now()) } pub fn dashboard_health_timeline_data_at( - records: &[AccountUsageTimeBucket], + records: &[RequestHealthTimeBucket], now: DateTime, ) -> DashboardHealthTimelineData { let current_slot = china_quarter_hour_start(now); @@ -335,7 +374,7 @@ pub fn dashboard_health_timeline_data_at( .map(|index| { ( start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index), - UsageWindow::default(), + HealthWindow::default(), ) }) .collect::>(); @@ -348,36 +387,62 @@ pub fn dashboard_health_timeline_data_at( .iter_mut() .find(|(bucket_start, _)| *bucket_start == record_slot) { - apply_bucket(bucket, record); + bucket.success_requests = bucket + .success_requests + .saturating_add(record.success_requests); + bucket.failed_requests = bucket + .failed_requests + .saturating_add(record.failed_requests); + bucket.cancelled_requests = bucket + .cancelled_requests + .saturating_add(record.cancelled_requests); + bucket.caller_error_requests = bucket + .caller_error_requests + .saturating_add(record.caller_error_requests); } } - let (requests, errors) = buckets + let totals = buckets .iter() .filter(|(bucket_start, _)| *bucket_start <= current_slot) - .fold((0, 0), |(requests, errors), (_, bucket)| { - (requests + bucket.requests, errors + bucket.errors) + .fold(HealthWindow::default(), |mut totals, (_, bucket)| { + totals.success_requests = totals + .success_requests + .saturating_add(bucket.success_requests); + totals.failed_requests = totals + .failed_requests + .saturating_add(bucket.failed_requests); + totals.cancelled_requests = totals + .cancelled_requests + .saturating_add(bucket.cancelled_requests); + totals.caller_error_requests = totals + .caller_error_requests + .saturating_add(bucket.caller_error_requests); + totals }); - let reliability = (requests > 0) - .then(|| ((requests - errors) as f64 / requests as f64 * 1000.0).round() / 10.0); DashboardHealthTimelineData { title: "请求健康时间线".to_string(), - description: "请求可靠性".to_string(), - reliability_display: reliability - .map(|value| format!("{value:.1}%")) - .unwrap_or_else(|| "-".to_string()), + description: "有效请求可用性".to_string(), + reliability_display: format_health_reliability(totals), + status: health_status(totals, false), + success_requests: totals.success_requests, + failed_requests: totals.failed_requests, + cancelled_requests: totals.cancelled_requests, + caller_error_requests: totals.caller_error_requests, points: buckets .into_iter() - .map(|(bucket_start, bucket)| { - let success_rate = if bucket.requests > 0 { - (bucket.requests.saturating_sub(bucket.errors) as f64 / bucket.requests as f64 - * 1000.0) - .round() - / 10.0 - } else { - 0.0 - }; - health_tone(bucket, success_rate, bucket_start > current_slot) + .enumerate() + .map(|(index, (bucket_start, bucket))| { + let elapsed_minutes = index as i64 * HEALTH_TIMELINE_SLOT_MINUTES; + DashboardHealthTimelinePointData { + time: format!("{:02}:{:02}", elapsed_minutes / 60, elapsed_minutes % 60), + status: health_status(bucket, bucket_start > current_slot), + reliability_display: format_health_reliability(bucket), + success_requests: bucket.success_requests, + failed_requests: bucket.failed_requests, + cancelled_requests: bucket.cancelled_requests, + caller_error_requests: bucket.caller_error_requests, + } }) .collect(), } @@ -510,23 +575,43 @@ fn first_token_bucket_latency(record: &AccountUsageTimeBucket) -> Option { .filter(|latency| *latency > 0) } -fn health_tone(bucket: UsageWindow, success_rate: f64, is_future: bool) -> char { - let successes = bucket.requests.saturating_sub(bucket.errors); +fn health_status(bucket: HealthWindow, is_future: bool) -> DashboardHealthTimelineStatus { + let eligible_requests = bucket + .success_requests + .saturating_add(bucket.failed_requests); if is_future { - '0' - } else if bucket.requests == 0 { - '1' - } else if successes == 0 { - '2' - } else if success_rate < 90.0 { - '3' - } else if successes < HEALTH_TIMELINE_STABLE_SUCCESS_THRESHOLD { - '4' + DashboardHealthTimelineStatus::Future + } else if eligible_requests == 0 { + DashboardHealthTimelineStatus::NoData + } else if bucket.success_requests == 0 + && bucket.failed_requests >= HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD + { + DashboardHealthTimelineStatus::Unavailable + } else if eligible_requests < HEALTH_TIMELINE_MIN_SAMPLE_SIZE { + DashboardHealthTimelineStatus::LowSample + } else if health_reliability(bucket) + .is_some_and(|reliability| reliability < HEALTH_TIMELINE_STABLE_RELIABILITY) + { + DashboardHealthTimelineStatus::Unstable } else { - '5' + DashboardHealthTimelineStatus::Stable } } +fn health_reliability(bucket: HealthWindow) -> Option { + let eligible_requests = bucket + .success_requests + .saturating_add(bucket.failed_requests); + (eligible_requests > 0) + .then(|| bucket.success_requests as f64 / eligible_requests as f64 * 100.0) +} + +fn format_health_reliability(bucket: HealthWindow) -> String { + health_reliability(bucket) + .map(|value| format!("{value:.1}%")) + .unwrap_or_else(|| "-".to_string()) +} + fn summary_cache_hit_rate(summary: &RetainedUsageSummary) -> Option { let input_tokens = nonnegative_i64_to_u64(summary.totals.input_tokens); (input_tokens > 0) diff --git a/backend/src/telemetry/usage/insights.rs b/backend/src/telemetry/usage/insights.rs index 456e30156..3309f1526 100644 --- a/backend/src/telemetry/usage/insights.rs +++ b/backend/src/telemetry/usage/insights.rs @@ -19,7 +19,8 @@ success_terminals as ( select distinct on (request_key) request_key, id, request_id, terminal_at, true as is_success, client_api_key_id, provider, account_id, transport, model_name, - null::text as failure_class, latency_ms, first_token_ms, billing_model, + null::text as failure_class, false as is_client_cancelled, + false as is_caller_error, latency_ms, first_token_ms, billing_model, service_tier, input_tokens, output_tokens, cached_tokens from ( select @@ -40,7 +41,8 @@ error_terminals as ( select distinct on (request_key) request_key, id, request_id, terminal_at, false as is_success, client_api_key_id, provider, account_id, transport, model_name, - failure_class, latency_ms, null::bigint as first_token_ms, + failure_class, is_client_cancelled, is_caller_error, + latency_ms, null::bigint as first_token_ms, null::text as billing_model, null::text as service_tier, null::bigint as input_tokens, null::bigint as output_tokens, null::bigint as cached_tokens @@ -56,6 +58,23 @@ error_terminals as ( nullif(metadata_json->>'terminal', ''), '未分类错误' ) as failure_class, + coalesce(( + coalesce( + nullif(failure_class, ''), + nullif(metadata_json->>'failureClass', ''), + nullif(metadata_json->>'upstreamCode', ''), + nullif(metadata_json->>'terminal', ''), + '' + ) in ('cancelled', 'downstream_closed', 'client_cancelled', 'consumer_dropped') + or ( + (status_code = 499 or client_status_code = 499) + and ( + metadata_json->>'cancelled' = 'true' + or message = 'v1 responses stream cancelled' + ) + ) + ), false) as is_client_cancelled, + coalesce(metadata_json->>'failureSource' = 'client', false) as is_caller_error, latency_ms from ops_error_logs where created_at >= $1 and created_at < $2 @@ -209,6 +228,8 @@ struct UsageHealthInsights { total_requests: u64, success_requests: u64, failed_requests: u64, + cancelled_requests: u64, + caller_error_requests: u64, success_rate: f64, request_change_rate: Option, success_rate_change: Option, @@ -222,9 +243,21 @@ struct UsageHealthPoint { label: String, success_requests: u64, failed_requests: u64, + cancelled_requests: u64, + caller_error_requests: u64, error_rate: f64, } +/// Dashboard 请求健康时间桶,仅统计每个请求的最终终态。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestHealthTimeBucket { + pub bucket_start: DateTime, + pub success_requests: u64, + pub failed_requests: u64, + pub cancelled_requests: u64, + pub caller_error_requests: u64, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct UsagePerformanceInsights { @@ -387,6 +420,47 @@ pub async fn diagnostics( }) } +/// 按 15 分钟聚合最终请求终态;客户端取消与调用方错误保持独立,不污染可用性。 +pub async fn health_timeline( + pool: &PgPool, + start: DateTime, + end: DateTime, +) -> PgUsageRecordStoreResult> { + let bucket = InsightsGranularity::QuarterHour.bucket_expression("terminal_at"); + let sql = format!( + "with {TERMINAL_REQUESTS_CTE}\n\ + select {bucket} as bucket_start,\n\ + count(*) filter (where is_success)::bigint as success_requests,\n\ + count(*) filter (\n\ + where not is_success and not is_client_cancelled and not is_caller_error\n\ + )::bigint as failed_requests,\n\ + count(*) filter (where is_client_cancelled)::bigint as cancelled_requests,\n\ + count(*) filter (\n\ + where is_caller_error and not is_client_cancelled\n\ + )::bigint as caller_error_requests\n\ + from terminal_requests\n\ + group by bucket_start\n\ + order by bucket_start" + ); + // SAFETY: 时间桶表达式与 CTE 都是模块内固定 SQL,时间范围仍使用绑定参数。 + let rows = sqlx::query(AssertSqlSafe(sql)) + .bind(start) + .bind(end) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| RequestHealthTimeBucket { + bucket_start: row.get("bucket_start"), + success_requests: nonnegative(row.get("success_requests")), + failed_requests: nonnegative(row.get("failed_requests")), + cancelled_requests: nonnegative(row.get("cancelled_requests")), + caller_error_requests: nonnegative(row.get("caller_error_requests")), + }) + .collect()) +} + async fn load_health( pool: &PgPool, start: DateTime, @@ -401,7 +475,13 @@ async fn load_health( select case when terminal_at >= $3 then 'current' else 'previous' end as period,\n\ {bucket} as bucket_start,\n\ count(*) filter (where is_success)::bigint as success_requests,\n\ - count(*) filter (where not is_success)::bigint as failed_requests\n\ + count(*) filter (\n\ + where not is_success and not is_client_cancelled and not is_caller_error\n\ + )::bigint as failed_requests,\n\ + count(*) filter (where is_client_cancelled)::bigint as cancelled_requests,\n\ + count(*) filter (\n\ + where is_caller_error and not is_client_cancelled\n\ + )::bigint as caller_error_requests\n\ from terminal_requests\n\ group by period, bucket_start\n\ order by bucket_start" @@ -417,17 +497,23 @@ async fn load_health( let mut current = HashMap::new(); let mut current_success = 0_u64; let mut current_failed = 0_u64; + let mut current_cancelled = 0_u64; + let mut current_caller_error = 0_u64; let mut previous_success = 0_u64; let mut previous_failed = 0_u64; for row in rows { let success = nonnegative(row.get("success_requests")); let failed = nonnegative(row.get("failed_requests")); + let cancelled = nonnegative(row.get("cancelled_requests")); + let caller_error = nonnegative(row.get("caller_error_requests")); if row.get::("period") == "current" { current_success = current_success.saturating_add(success); current_failed = current_failed.saturating_add(failed); + current_cancelled = current_cancelled.saturating_add(cancelled); + current_caller_error = current_caller_error.saturating_add(caller_error); current.insert( row.get::, _>("bucket_start"), - (success, failed), + (success, failed, cancelled, caller_error), ); } else { previous_success = previous_success.saturating_add(success); @@ -441,7 +527,7 @@ async fn load_health( let points = bucket_starts(start, end, granularity) .into_iter() .map(|bucket| { - let (success_requests, failed_requests) = + let (success_requests, failed_requests, cancelled_requests, caller_error_requests) = current.get(&bucket).copied().unwrap_or_default(); let point_total = success_requests.saturating_add(failed_requests); UsageHealthPoint { @@ -449,6 +535,8 @@ async fn load_health( label: bucket_label(bucket, granularity), success_requests, failed_requests, + cancelled_requests, + caller_error_requests, error_rate: rate(failed_requests, point_total), } }) @@ -457,6 +545,8 @@ async fn load_health( total_requests: total, success_requests: current_success, failed_requests: current_failed, + cancelled_requests: current_cancelled, + caller_error_requests: current_caller_error, success_rate, request_change_rate: relative_change(total, previous_total), success_rate_change: (previous_total > 0).then_some(success_rate - previous_success_rate), diff --git a/backend/src/telemetry/usage/query.rs b/backend/src/telemetry/usage/query.rs index c58e9407d..1fcb2a23f 100644 --- a/backend/src/telemetry/usage/query.rs +++ b/backend/src/telemetry/usage/query.rs @@ -11,8 +11,8 @@ use crate::{ telemetry::{ billing, usage::insights::{ - UsageDiagnosticsDimension, UsageDiagnosticsInsights, UsageInsightsOverview, - diagnostics, overview, + RequestHealthTimeBucket, UsageDiagnosticsDimension, UsageDiagnosticsInsights, + UsageInsightsOverview, diagnostics, health_timeline, overview, }, usage::store::{ PgUsageRecordStore, PgUsageRecordStoreResult, UsageRecordFilter, push_filter, @@ -239,6 +239,17 @@ impl UsageQueryService { .map_err(|_| UsageQueryError::List) } + /// 读取按最终请求终态去重的 15 分钟健康时间桶。 + pub async fn health_timeline( + &self, + start: DateTime, + end: DateTime, + ) -> Result, UsageQueryError> { + health_timeline(self.store.pool(), start, end) + .await + .map_err(|_| UsageQueryError::List) + } + /// 按维度读取请求热点诊断。 pub async fn insights_diagnostics( &self, diff --git a/backend/tests/api/admin/dashboard_routes.rs b/backend/tests/api/admin/dashboard_routes.rs index e41538326..261e774c3 100644 --- a/backend/tests/api/admin/dashboard_routes.rs +++ b/backend/tests/api/admin/dashboard_routes.rs @@ -650,12 +650,21 @@ async fn dashboard_summary_should_return_today_health_timeline() { .unwrap(); let body = dashboard_summary(app).await; - let points = body["data"]["healthTimeline"]["points"].as_str().unwrap(); + let points = body["data"]["healthTimeline"]["points"].as_array().unwrap(); assert_eq!(body["data"]["healthTimeline"]["title"], "请求健康时间线"); - assert_eq!(body["data"]["healthTimeline"]["description"], "请求可靠性"); + assert_eq!( + body["data"]["healthTimeline"]["description"], + "有效请求可用性" + ); assert_eq!(points.len(), 96); - assert_eq!(points.matches('4').count(), 1); + assert_eq!( + points + .iter() + .filter(|point| point["status"] == "low_sample") + .count(), + 1 + ); } #[tokio::test] diff --git a/backend/tests/bootstrap/config/mod.rs b/backend/tests/bootstrap/config/mod.rs index da95efd41..743b01702 100644 --- a/backend/tests/bootstrap/config/mod.rs +++ b/backend/tests/bootstrap/config/mod.rs @@ -160,7 +160,7 @@ fn config_loader_should_reject_missing_explicit_fields() { #[test] fn config_loader_should_reject_unsupported_schema_version() { - let yaml = complete_config().replace("schema_version: 2", "schema_version: 1"); + let yaml = complete_config().replace("schema_version: 1", "schema_version: 0"); let directory = write_config(&yaml); let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); diff --git a/backend/tests/telemetry/dashboard.rs b/backend/tests/telemetry/dashboard.rs new file mode 100644 index 000000000..abc3423de --- /dev/null +++ b/backend/tests/telemetry/dashboard.rs @@ -0,0 +1,65 @@ +use chrono::{Duration, TimeZone, Utc}; +use codex_proxy_rs::{ + infra::time::china_day_start, + telemetry::{ + dashboard::dashboard_health_timeline_data_at, usage::insights::RequestHealthTimeBucket, + }, +}; +use serde_json::json; + +#[test] +fn dashboard_health_timeline_should_classify_only_eligible_final_requests() { + let now = Utc.with_ymd_and_hms(2026, 7, 15, 5, 0, 0).unwrap(); + let start = china_day_start(now); + let records = vec![ + health_bucket(start, 0, 0, 7, 2), + health_bucket(start + Duration::minutes(15), 9, 0, 0, 0), + health_bucket(start + Duration::minutes(30), 0, 3, 0, 0), + health_bucket(start + Duration::minutes(45), 98, 2, 0, 0), + health_bucket(start + Duration::minutes(60), 99, 1, 0, 0), + ]; + + let value = serde_json::to_value(dashboard_health_timeline_data_at(&records, now)).unwrap(); + let points = value["points"].as_array().unwrap(); + + assert_eq!( + json!({ + "description": value["description"], + "reliabilityDisplay": value["reliabilityDisplay"], + "status": value["status"], + "successRequests": value["successRequests"], + "failedRequests": value["failedRequests"], + "cancelledRequests": value["cancelledRequests"], + "callerErrorRequests": value["callerErrorRequests"], + "pointCount": points.len(), + "statuses": points.iter().take(5).map(|point| &point["status"]).collect::>(), + }), + json!({ + "description": "有效请求可用性", + "reliabilityDisplay": "97.2%", + "status": "unstable", + "successRequests": 206, + "failedRequests": 6, + "cancelledRequests": 7, + "callerErrorRequests": 2, + "pointCount": 96, + "statuses": ["no_data", "low_sample", "unavailable", "unstable", "stable"], + }) + ); +} + +fn health_bucket( + bucket_start: chrono::DateTime, + success_requests: u64, + failed_requests: u64, + cancelled_requests: u64, + caller_error_requests: u64, +) -> RequestHealthTimeBucket { + RequestHealthTimeBucket { + bucket_start, + success_requests, + failed_requests, + cancelled_requests, + caller_error_requests, + } +} diff --git a/backend/tests/telemetry/mod.rs b/backend/tests/telemetry/mod.rs index 8e3795c64..6831e01a6 100644 --- a/backend/tests/telemetry/mod.rs +++ b/backend/tests/telemetry/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod account_usage; +mod dashboard; pub(crate) mod ops; pub(crate) mod rebuild; pub(crate) mod usage; diff --git a/backend/tests/telemetry/usage/insights.rs b/backend/tests/telemetry/usage/insights.rs new file mode 100644 index 000000000..fe929e56d --- /dev/null +++ b/backend/tests/telemetry/usage/insights.rs @@ -0,0 +1,108 @@ +use chrono::{Duration, TimeZone, Utc}; +use codex_proxy_rs::telemetry::{ + ops::{store::PgOpsErrorLogStore, types::OpsErrorLog}, + usage::{ + insights::{RequestHealthTimeBucket, health_timeline}, + store::PgUsageRecordStore, + types::UsageRecord, + }, +}; +use serde_json::json; + +use crate::support::storage::init_test_db; + +#[tokio::test] +async fn health_timeline_should_use_final_request_outcomes_and_keep_neutral_counts_separate() { + let (pool, _guard) = init_test_db("usage-health-timeline-final-outcomes").await; + let usage_store = PgUsageRecordStore::new(pool.clone()); + let error_store = PgOpsErrorLogStore::new(pool.clone()); + let start = Utc.with_ymd_and_hms(2026, 7, 15, 12, 0, 0).unwrap(); + + error_store + .append(&service_error( + "ops_recovered", + "req_recovered", + start + Duration::minutes(1), + )) + .await + .unwrap(); + usage_store + .append(&success( + "usage_recovered", + "req_recovered", + start + Duration::minutes(2), + )) + .await + .unwrap(); + usage_store + .append(&success( + "usage_success", + "req_success", + start + Duration::minutes(3), + )) + .await + .unwrap(); + + for (id, minute) in [("ops_failed_first", 4), ("ops_failed_latest", 5)] { + error_store + .append(&service_error( + id, + "req_failed", + start + Duration::minutes(minute), + )) + .await + .unwrap(); + } + + let mut cancelled = service_error( + "ops_cancelled", + "req_cancelled", + start + Duration::minutes(6), + ); + cancelled.status_code = Some(499); + cancelled.failure_class = None; + cancelled.metadata = json!({ "terminal": "cancelled" }); + error_store.append(&cancelled).await.unwrap(); + + let mut caller_error = service_error( + "ops_caller_error", + "req_caller_error", + start + Duration::minutes(7), + ); + caller_error.status_code = Some(400); + caller_error.metadata = json!({ "failureSource": "client" }); + error_store.append(&caller_error).await.unwrap(); + + let buckets = health_timeline(&pool, start, start + Duration::minutes(15)) + .await + .unwrap(); + + assert_eq!( + buckets, + vec![RequestHealthTimeBucket { + bucket_start: start, + success_requests: 2, + failed_requests: 1, + cancelled_requests: 1, + caller_error_requests: 1, + }] + ); +} + +fn success(id: &str, request_id: &str, created_at: chrono::DateTime) -> UsageRecord { + let mut record = UsageRecord::new("v1.response", "completed", "acct_health", "gpt-5", 200); + record.id = id.to_string(); + record.request_id = Some(request_id.to_string()); + record.created_at = created_at; + record +} + +fn service_error(id: &str, request_id: &str, created_at: chrono::DateTime) -> OpsErrorLog { + let mut error = OpsErrorLog::new("v1.response", "request failed"); + error.id = id.to_string(); + error.request_id = Some(request_id.to_string()); + error.status_code = Some(503); + error.failure_class = Some("upstream_unavailable".to_string()); + error.created_at = created_at; + error +} diff --git a/backend/tests/telemetry/usage/mod.rs b/backend/tests/telemetry/usage/mod.rs index 3d6074864..3c2e166bb 100644 --- a/backend/tests/telemetry/usage/mod.rs +++ b/backend/tests/telemetry/usage/mod.rs @@ -1 +1,2 @@ +mod insights; mod store; diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index a3a1ac98f..853b582f2 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -8,7 +8,7 @@ # 三个密码建议分别执行 `openssl rand -hex 24` 生成。 x-cpr: - schema_version: 2 + schema_version: 1 server: # 本地 cargo run 使用;Docker 内由 compose.yaml 固定覆盖为 0.0.0.0:8080。 diff --git a/frontend/src/components/base/BasePopover.vue b/frontend/src/components/base/BasePopover.vue index 2b57eeacf..9119f25cb 100644 --- a/frontend/src/components/base/BasePopover.vue +++ b/frontend/src/components/base/BasePopover.vue @@ -1,13 +1,7 @@ @@ -331,6 +344,7 @@ onBeforeUnmount(clearHoverCloseTimer)
@@ -358,6 +372,11 @@ onBeforeUnmount(clearHoverCloseTimer) > diff --git a/frontend/src/components/base/BaseTable/columns.ts b/frontend/src/components/base/BaseTable/columns.ts index 5f5e56158..6bff56224 100644 --- a/frontend/src/components/base/BaseTable/columns.ts +++ b/frontend/src/components/base/BaseTable/columns.ts @@ -10,6 +10,7 @@ export interface BaseTableColumn { minWidth?: number | string maxWidth?: number | string flex?: number + fixed?: 'left' | 'right' | false align?: 'left' | 'right' | 'center' ellipsis?: boolean emptyText?: string @@ -24,7 +25,7 @@ export interface BaseTableSort { direction: BaseTableSortDirection } -export type ResolvedTableColumn = BaseTableColumn & { +export type ResolvedTableColumn = Omit & { fixed?: 'left' | 'right' resolvedWidth: string resolvedMinWidth?: string @@ -51,6 +52,8 @@ export function resolveColumns( return columns.map((column, index) => { const flex = column.flex ?? 1 + const automaticallyFixed = + index === fixedColumnIndex ? (actionColumnIndex >= 0 ? 'right' : 'left') : undefined const width = column.width === undefined ? minWidthPixels > 0 && fixedPercentTotal === 0 @@ -64,7 +67,7 @@ export function resolveColumns( return { ...column, - fixed: index === fixedColumnIndex ? (actionColumnIndex >= 0 ? 'right' : 'left') : undefined, + fixed: column.fixed === false ? undefined : (column.fixed ?? automaticallyFixed), resolvedWidth: width, resolvedMinWidth: column.minWidth === undefined ? undefined : normalizeWidth(column.minWidth), resolvedMaxWidth: column.maxWidth === undefined ? undefined : normalizeWidth(column.maxWidth), diff --git a/frontend/src/views/dashboard/components/HealthTimelinePointPopover.vue b/frontend/src/views/dashboard/components/HealthTimelinePointPopover.vue new file mode 100644 index 000000000..5c565ecf7 --- /dev/null +++ b/frontend/src/views/dashboard/components/HealthTimelinePointPopover.vue @@ -0,0 +1,168 @@ + + + diff --git a/frontend/src/views/dashboard/components/RequestHealthTimelineCard.vue b/frontend/src/views/dashboard/components/RequestHealthTimelineCard.vue index 2f55a8e1e..efe191a0e 100644 --- a/frontend/src/views/dashboard/components/RequestHealthTimelineCard.vue +++ b/frontend/src/views/dashboard/components/RequestHealthTimelineCard.vue @@ -1,39 +1,201 @@ diff --git a/frontend/src/views/dashboard/components/RequestTrendCard.vue b/frontend/src/views/dashboard/components/RequestTrendCard.vue index 5dceea4c5..f4f3ea16b 100644 --- a/frontend/src/views/dashboard/components/RequestTrendCard.vue +++ b/frontend/src/views/dashboard/components/RequestTrendCard.vue @@ -54,14 +54,13 @@ const chartOption = computed(() => { return { ...coordinate, series, - axisPointer: - activeKind.value === 'usage' - ? { - link: [{ xAxisIndex: [0, 1, 2] }], - } - : undefined, + animationDuration: 420, + animationDurationUpdate: 220, + animationEasing: 'cubicOut', + animationEasingUpdate: 'cubicOut', tooltip: { trigger: 'axis', + confine: true, backgroundColor: themeColor('--cp-bg-surface', '#fff'), borderColor: 'transparent', borderWidth: 0, @@ -84,7 +83,8 @@ const chartOption = computed(() => { function getCoordinateSystem(times: string[]) { const muted = themeColor('--cp-text-muted', '#94A3B8') - const gridLine = themeColor('--cp-bg-muted', '#F1F5F9') + const gridLine = themeColor('--cp-divider-subtle', '#E2E8F0') + const axisLine = themeColor('--cp-default-border', '#E2E8F0') const timeLabels = { color: muted, fontSize: 10, @@ -93,80 +93,85 @@ function getCoordinateSystem(times: string[]) { interval: showTwoHourLabel, } - if (activeKind.value === 'usage') { - return getUsageCoordinateSystem(times, timeLabels, gridLine) - } - - const xAxis = { - type: 'category' as const, - data: times, - axisLine: { show: false }, - axisTick: { show: false }, - } - const yAxis = { - type: 'value' as const, - min: 0, - splitNumber: 2, - axisLabel: { show: false }, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { lineStyle: { color: gridLine } }, - } - return { - grid: { left: 4, right: 0, top: 8, bottom: 24 }, + grid: { + left: 8, + right: activeKind.value === 'latency' ? 8 : 10, + top: 10, + bottom: 2, + containLabel: true, + }, xAxis: { - ...xAxis, - boundaryGap: activeKind.value !== 'latency', + type: 'category' as const, + data: times, + boundaryGap: activeKind.value === 'errors', + axisLine: { show: true, lineStyle: { color: axisLine } }, + axisTick: { show: false }, axisLabel: timeLabels, }, yAxis: [ - yAxis, { type: 'value' as const, min: 0, - max: 100, + splitNumber: 3, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: muted, + fontSize: 9, + fontFamily: 'JetBrains Mono Variable, JetBrains Mono', + formatter: formatPrimaryAxisValue, + }, + splitLine: { + show: true, + lineStyle: { color: gridLine, type: 'dashed' as const, opacity: 0.72 }, + }, + }, + { + type: 'value' as const, + min: 0, + max: activeKind.value === 'errors' ? 100 : undefined, + splitNumber: 3, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + show: activeKind.value !== 'latency', + color: + activeKind.value === 'usage' + ? trendColor('输出', '--cp-success', '#10B981') + : trendColor('成功', '--cp-success', '#10B981'), + fontSize: 9, + fontFamily: 'JetBrains Mono Variable, JetBrains Mono', + formatter: formatSecondaryAxisValue, + }, splitLine: { show: false }, - axisLabel: { show: false }, }, ], } } -function getUsageCoordinateSystem(times: string[], timeLabels: any, gridLine: string) { - const xAxis = (gridIndex: number, showLabels = false) => ({ - type: 'category' as const, - data: times, - gridIndex, - boundaryGap: false, - axisLine: { - show: true, - lineStyle: { color: gridLine }, - }, - axisTick: { show: false }, - axisLabel: showLabels ? timeLabels : { show: false }, - axisPointer: { show: true }, - }) - const yAxis = (gridIndex: number) => ({ - type: 'value' as const, - gridIndex, - min: 0, - max: (range: any) => (range.max > 0 ? range.max * 1.12 : 1), - axisLabel: { show: false }, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { show: false }, - }) - - return { - grid: [ - { left: 4, right: 0, top: 0, height: 76 }, - { left: 4, right: 0, top: 91, height: 66 }, - { left: 4, right: 0, top: 172, bottom: 18 }, - ], - xAxis: [xAxis(0), xAxis(1), xAxis(2, true)], - yAxis: [yAxis(0), yAxis(1), yAxis(2)], +function formatPrimaryAxisValue(value: number) { + if (activeKind.value === 'latency') { + return value >= 1000 ? `${formatAxisNumber(value / 1000)}s` : `${Math.round(value)}ms` } + return formatAxisCompact(value) +} + +function formatSecondaryAxisValue(value: number) { + if (activeKind.value === 'errors') return `${Math.round(value)}%` + return formatAxisCompact(value) +} + +function formatAxisCompact(value: number) { + const normalized = Math.abs(value) + if (normalized >= 1_000_000_000) return `${formatAxisNumber(value / 1_000_000_000)}B` + if (normalized >= 1_000_000) return `${formatAxisNumber(value / 1_000_000)}M` + if (normalized >= 1_000) return `${formatAxisNumber(value / 1_000)}K` + return `${Math.round(value)}` +} + +function formatAxisNumber(value: number) { + return value >= 10 ? value.toFixed(0) : value.toFixed(1).replace(/\.0$/, '') } function showTwoHourLabel(_index: number, value: string) { @@ -248,77 +253,67 @@ function tooltipIndex(source: unknown) { function getSeries() { if (activeKind.value === 'usage') { + const cacheColor = trendColor('缓存', '--cp-text-tertiary', '#94A3B8') + const inputColor = trendColor('输入', '--cp-info', '#2563EB') + const outputColor = trendColor('输出', '--cp-success', '#10B981') + return [ - lineSeries( - '输入', - activeSeriesValues('inputTokensValue'), - trendColor('输入', '--cp-info', '#2563EB'), - { - area: true, - smooth: 0.22, - width: 2, - xAxisIndex: 0, - yAxisIndex: 0, - }, - ), - lineSeries( - '输出', - activeSeriesValues('outputTokensValue'), - trendColor('输出', '--cp-success', '#10B981'), - { - area: true, - smooth: 0.22, - width: 1.9, - xAxisIndex: 1, - yAxisIndex: 1, - }, - ), - lineSeries( - '缓存', - activeSeriesValues('cachedTokensValue'), - trendColor('缓存', '--cp-text-tertiary', '#94A3B8'), - { - area: true, - smooth: 0.22, - width: 1.8, - xAxisIndex: 2, - yAxisIndex: 2, - }, - ), + lineSeries('缓存', activeSeriesValues('cachedTokensValue'), cacheColor, { + area: true, + areaStartAlpha: '30', + areaEndAlpha: '08', + lineType: 'dashed', + smooth: 0.26, + stack: 'input-composition', + width: 1.25, + z: 1, + }), + lineSeries('输入', activeSeriesValues('uncachedInputTokensValue'), inputColor, { + area: true, + areaStartAlpha: '2A', + areaEndAlpha: '05', + smooth: 0.26, + stack: 'input-composition', + width: 2.3, + z: 3, + }), + lineSeries('输出', activeSeriesValues('outputTokensValue'), outputColor, { + smooth: 0.24, + width: 2.1, + yAxisIndex: 1, + z: 4, + }), ] } - if (isSparseTrend.value) return getSparseSeries() - if (activeKind.value === 'latency') { + const averageColor = trendColor('平均', '--cp-normal', '#0F9F9A') + const maximumColor = trendColor('最高', '--cp-warning', '#F59E0B') + const minimumColor = trendColor('最低', '--cp-success', '#10B981') + return [ - lineSeries( - '平均', - seriesValues('latencyValue'), - trendColor('平均', '--cp-normal', '#0F9F9A'), - { - area: true, - width: 2.6, - }, - ), - lineSeries( - '最高', - seriesValues('maxLatencyValue'), - trendColor('最高', '--cp-warning', '#F59E0B'), - { - lineType: 'dashed', - width: 1.8, - }, - ), - lineSeries( - '最低', - seriesValues('minLatencyValue'), - trendColor('最低', '--cp-success', '#10B981'), - { - lineType: 'dotted', - width: 1.8, - }, - ), + lineSeries('最低', activeSeriesValues('minLatencyValue'), minimumColor, { + lineType: 'dotted', + smooth: 0.2, + stack: 'latency-range', + width: 1.2, + z: 1, + }), + lineSeries('最高', latencyRangeValues(), maximumColor, { + area: true, + areaStartAlpha: '26', + areaEndAlpha: '10', + lineType: 'dashed', + smooth: 0.2, + stack: 'latency-range', + width: 1.2, + z: 1, + }), + lineSeries('平均', activeSeriesValues('latencyValue'), averageColor, { + smooth: 0.24, + width: 2.5, + z: 4, + }), ] } return [ @@ -344,61 +339,6 @@ function getSeries() { ] } -function getSparseSeries() { - if (activeKind.value === 'latency') { - return [ - barSeries( - '平均', - activeSeriesValues('latencyValue'), - trendColor('平均', '--cp-normal', '#0F9F9A'), - { - maxWidth: 9, - opacity: 0.78, - }, - ), - barSeries( - '最高', - activeSeriesValues('maxLatencyValue'), - trendColor('最高', '--cp-warning', '#F59E0B'), - { maxWidth: 9, opacity: 0.58 }, - ), - barSeries( - '最低', - activeSeriesValues('minLatencyValue'), - trendColor('最低', '--cp-success', '#10B981'), - { maxWidth: 9, opacity: 0.58 }, - ), - ] - } - - return [ - barSeries( - '错误数', - activeSeriesValues('errorsValue'), - trendColor('错误', '--cp-danger', '#EF4444'), - { - maxWidth: 10, - opacity: 0.72, - }, - ), - pulseSeries( - '成功率', - activeSeriesValues('successRateValue'), - trendColor('成功', '--cp-success', '#10B981'), - { yAxisIndex: 1 }, - ), - barSeries( - '总请求', - activeSeriesValues('requestsValue'), - trendColor('请求', '--cp-info', '#2563EB'), - { - maxWidth: 10, - opacity: 0.5, - }, - ), - ] -} - function seriesValues(key: string) { return props.points.map((point) => point[key] ?? null) } @@ -409,6 +349,18 @@ function activeSeriesValues(key: string) { ) } +function latencyRangeValues() { + return props.points.map((point) => { + if (Number(point.requestsValue) <= 0) return null + if (point.minLatencyValue === null || point.maxLatencyValue === null) return null + if (point.minLatencyValue === undefined || point.maxLatencyValue === undefined) return null + const minimum = Number(point.minLatencyValue) + const maximum = Number(point.maxLatencyValue) + if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) return null + return Math.max(0, maximum - minimum) + }) +} + function lineSeries(name: string, data: (number | null)[], color: string, options: any = {}) { const area = options.area ?? false @@ -418,10 +370,13 @@ function lineSeries(name: string, data: (number | null)[], color: string, option data, connectNulls: false, smooth: options.smooth ?? true, - symbol: options.symbol ?? 'none', + symbol: options.symbol ?? (isSparseTrend.value ? 'circle' : 'none'), symbolSize: options.symbolSize ?? 5, + showSymbol: options.showSymbol ?? isSparseTrend.value, + stack: options.stack, xAxisIndex: options.xAxisIndex ?? 0, yAxisIndex: options.yAxisIndex ?? 0, + z: options.z ?? 2, lineStyle: { color, type: options.lineType ?? 'solid', @@ -437,8 +392,8 @@ function lineSeries(name: string, data: (number | null)[], color: string, option x2: 0, y2: 1, colorStops: [ - { offset: 0, color: `${color}18` }, - { offset: 1, color: `${color}02` }, + { offset: 0, color: `${color}${options.areaStartAlpha ?? '18'}` }, + { offset: 1, color: `${color}${options.areaEndAlpha ?? '02'}` }, ], }, } @@ -446,21 +401,6 @@ function lineSeries(name: string, data: (number | null)[], color: string, option } } -function pulseSeries(name: string, data: (number | null)[], color: string, options: any = {}) { - return { - name, - type: 'line' as const, - data, - connectNulls: false, - smooth: false, - symbol: 'roundRect', - symbolSize: [5, 16], - yAxisIndex: options.yAxisIndex ?? 0, - lineStyle: { opacity: 0 }, - itemStyle: { color }, - } -} - function barSeries(name: string, data: (number | null)[], color: string, options: any = {}) { return { name, @@ -505,39 +445,17 @@ function handleTrendChange(value: string) { diff --git a/frontend/src/views/dashboard/components/WireProfileCard.vue b/frontend/src/views/dashboard/components/WireProfileCard.vue index f5f1faaed..a8abdfd87 100644 --- a/frontend/src/views/dashboard/components/WireProfileCard.vue +++ b/frontend/src/views/dashboard/components/WireProfileCard.vue @@ -1,5 +1,5 @@