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 <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-15 23:52:35 +08:00
co-authored by Codex
parent 0b2da3087b
commit 72d892d5e3
25 changed files with 1227 additions and 404 deletions
+18 -2
View File
@@ -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<Utc>,
) -> Result<Vec<RequestHealthTimeBucket>, 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,
+2 -2
View File
@@ -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")]
+122 -37
View File
@@ -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<DashboardHealthTimelinePointData>,
}
#[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<Utc>,
) -> 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::<Vec<_>>();
@@ -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<u64> {
.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<f64> {
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<f64> {
let input_tokens = nonnegative_i64_to_u64(summary.totals.input_tokens);
(input_tokens > 0)
+95 -5
View File
@@ -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<f64>,
success_rate_change: Option<f64>,
@@ -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<Utc>,
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<Utc>,
end: DateTime<Utc>,
) -> PgUsageRecordStoreResult<Vec<RequestHealthTimeBucket>> {
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<Utc>,
@@ -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::<String, _>("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::<DateTime<Utc>, _>("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),
+13 -2
View File
@@ -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<Utc>,
end: DateTime<Utc>,
) -> Result<Vec<RequestHealthTimeBucket>, UsageQueryError> {
health_timeline(self.store.pool(), start, end)
.await
.map_err(|_| UsageQueryError::List)
}
/// 按维度读取请求热点诊断。
pub async fn insights_diagnostics(
&self,
+12 -3
View File
@@ -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]
+1 -1
View File
@@ -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();
+65
View File
@@ -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::<Vec<_>>(),
}),
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<Utc>,
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,
}
}
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod account_usage;
mod dashboard;
pub(crate) mod ops;
pub(crate) mod rebuild;
pub(crate) mod usage;
+108
View File
@@ -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<Utc>) -> 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<Utc>) -> 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
}
+1
View File
@@ -1 +1,2 @@
mod insights;
mod store;
+1 -1
View File
@@ -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。
+35 -16
View File
@@ -1,13 +1,7 @@
<script setup lang="ts">
import {
onClickOutside,
onKeyStroke,
useEventListener,
useThrottleFn,
whenever,
} from '@vueuse/core'
import { onClickOutside, useEventListener, useThrottleFn, whenever } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useAttrs } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useAttrs, watch } from 'vue'
import type { CSSProperties } from 'vue'
type PopoverPlacement =
@@ -36,6 +30,9 @@ const props = withDefaults(
width?: number | string
disabled?: boolean
panelClass?: string
triggerClass?: string
anchorElement?: HTMLElement | null
animatePosition?: boolean
}>(),
{
placement: 'bottom-end',
@@ -43,6 +40,9 @@ const props = withDefaults(
offset: 6,
disabled: false,
panelClass: '',
triggerClass: '',
anchorElement: null,
animatePosition: false,
},
)
@@ -55,9 +55,13 @@ const popoverRef = ref<HTMLElement | null>(null)
const popoverStyle = ref<CSSProperties>({})
const popoverArrowStyle = ref<CSSProperties>({})
const hoverCloseTimer = shallowRef<number>()
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
const popoverClasses = computed(() => [
'fixed z-50 overflow-visible rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1.5 text-left shadow-(--cp-shadow-popover)',
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined,
props.panelClass,
])
@@ -76,10 +80,11 @@ function toCssLength(value?: number | string) {
}
function updatePopoverPosition() {
if (!open.value || !triggerRef.value) return
const anchorElement = props.anchorElement ?? triggerRef.value
if (!open.value || !anchorElement) return
const viewportPadding = 8
const triggerRect = triggerRef.value.getBoundingClientRect()
const triggerRect = anchorElement.getBoundingClientRect()
const panelRect = popoverRef.value?.getBoundingClientRect()
const panelWidth = Math.max(
panelRect?.width ?? 0,
@@ -314,15 +319,23 @@ whenever(open, async () => {
await nextTick()
updatePopoverPosition()
})
watch(
() => props.anchorElement,
async () => {
if (!open.value) return
await nextTick()
updatePopoverPosition()
},
)
onClickOutside(rootRef, closePopover, { ignore: [popoverRef] })
onKeyStroke('Escape', () => {
if (open.value) {
useEventListener(viewportTarget, 'keydown', (event) => {
if (event instanceof KeyboardEvent && event.key === 'Escape') {
closePopover()
}
})
useEventListener(window, 'resize', updatePopoverPositionThrottled)
useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: true })
useEventListener(viewportTarget, 'resize', updatePopoverPositionThrottled)
useEventListener(viewportTarget, 'scroll', updatePopoverPositionThrottled, { capture: true })
onBeforeUnmount(clearHoverCloseTimer)
</script>
@@ -331,6 +344,7 @@ onBeforeUnmount(clearHoverCloseTimer)
<div
ref="triggerRef"
class="inline-flex"
:class="props.triggerClass"
@click.stop="togglePopover"
@keydown="handleTriggerKeydown"
@mouseenter="handleHoverEnter"
@@ -341,10 +355,10 @@ onBeforeUnmount(clearHoverCloseTimer)
<Teleport to="body">
<Transition
enter-active-class="transition-[opacity,transform] duration-150 ease-out"
enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
enter-from-class="-translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-active-class="transition-opacity duration-150 ease-in motion-reduce:transition-none"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
@@ -358,6 +372,11 @@ onBeforeUnmount(clearHoverCloseTimer)
>
<span
class="pointer-events-none absolute size-2 rotate-45 bg-inherit"
:class="
props.animatePosition
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
: undefined
"
:style="popoverArrowStyle"
/>
<slot :open="open" :close="closePopover" :toggle="togglePopover" />
@@ -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<BaseTableColumn, 'fixed'> & {
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),
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
formatHealthCount,
healthReliabilityValueClass,
healthStatusMeta,
type HealthTimelinePoint,
} from '../constants'
const props = defineProps<{
point: HealthTimelinePoint
}>()
const status = computed(() => healthStatusMeta[props.point.status])
const eligibleRequests = computed(() => props.point.successRequests + props.point.failedRequests)
const observedRequests = computed(
() => eligibleRequests.value + props.point.cancelledRequests + props.point.callerErrorRequests,
)
const outcomeSegments = computed(() => {
const total = observedRequests.value
if (total === 0) return []
return [
{
label: '成功',
value: props.point.successRequests,
className: 'bg-(--cp-success)',
},
{
label: '服务失败',
value: props.point.failedRequests,
className: 'bg-(--cp-danger)',
},
{
label: '客户端取消',
value: props.point.cancelledRequests,
className: 'bg-(--cp-normal)',
},
{
label: '调用方错误',
value: props.point.callerErrorRequests,
className: 'bg-(--cp-info)',
},
]
.filter((item) => item.value > 0)
.map((item) => ({
...item,
percentage: (item.value / total) * 100,
}))
})
const metricItems = computed(() => [
{
label: '成功',
value: props.point.successRequests,
dotClass: 'bg-(--cp-success)',
valueClass: 'text-(--cp-success-text)',
},
{
label: '服务失败',
value: props.point.failedRequests,
dotClass: 'bg-(--cp-danger)',
valueClass: 'text-(--cp-danger-text)',
},
{
label: '客户端取消',
value: props.point.cancelledRequests,
dotClass: 'bg-(--cp-normal)',
valueClass: 'text-(--cp-normal-text)',
},
{
label: '调用方错误',
value: props.point.callerErrorRequests,
dotClass: 'bg-(--cp-info)',
valueClass: 'text-(--cp-info-text)',
},
])
</script>
<template>
<section
role="dialog"
:aria-label="`${point.time} 请求健康详情`"
class="overflow-hidden rounded-(--cp-popover-radius)"
>
<header class="flex items-center justify-between gap-3 bg-(--cp-bg-subtle) px-3.5 py-3">
<div class="flex min-w-0 items-center gap-2">
<span class="size-2 shrink-0 rounded-full" :class="status.cellClass" />
<strong class="font-mono text-[13px] leading-none font-[780] text-(--cp-text-primary)">
{{ point.time }}
</strong>
<span
class="inline-flex h-5 items-center rounded-full px-2 text-[10px] leading-none font-[760]"
:class="status.badgeClass"
>
{{ status.label }}
</span>
</div>
<span class="shrink-0 text-[10px] leading-none font-[650] text-(--cp-text-muted)">
15 分钟
</span>
</header>
<div class="grid gap-3.5 px-3.5 py-3.5">
<div class="flex items-end justify-between gap-4">
<div>
<p class="m-0 text-[11px] leading-none font-[650] text-(--cp-text-muted)">
有效请求可用性
</p>
<strong
class="mt-2 block font-mono text-[25px] leading-none font-[790] tabular-nums"
:class="healthReliabilityValueClass(point.successRequests, point.failedRequests)"
>
{{ point.reliabilityDisplay }}
</strong>
</div>
<div class="text-right">
<span class="block text-[10px] leading-none font-[650] text-(--cp-text-muted)">
有效请求
</span>
<strong
class="mt-2 block font-mono text-[15px] leading-none font-[760] tabular-nums text-(--cp-text-primary)"
>
{{ formatHealthCount(eligibleRequests) }}
</strong>
</div>
</div>
<div
class="flex h-1.5 w-full overflow-hidden rounded-full bg-(--cp-bg-muted)"
aria-hidden="true"
>
<span
v-for="segment in outcomeSegments"
:key="segment.label"
class="h-full"
:class="segment.className"
:style="{ flexBasis: `${segment.percentage}%` }"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div
v-for="item in metricItems"
:key="item.label"
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-2.5 py-2.5"
>
<span class="size-1.5 rounded-full" :class="item.dotClass" />
<span class="truncate text-[10px] leading-none font-[650] text-(--cp-text-secondary)">
{{ item.label }}
</span>
<strong
class="font-mono text-[12px] leading-none font-[760] tabular-nums"
:class="item.valueClass"
>
{{ formatHealthCount(item.value) }}
</strong>
</div>
</div>
<p
class="m-0 border-t border-(--cp-divider-subtle) pt-3 text-[10px] leading-[1.45] font-[650] text-(--cp-text-muted)"
>
客户端取消与调用方错误单独记录,不计入可用性。
</p>
</div>
</section>
</template>
@@ -1,39 +1,201 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePreferredReducedMotion } from '@vueuse/core'
import { gsap } from 'gsap'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BasePopover from '../../../components/base/BasePopover.vue'
import HealthTimelinePointPopover from './HealthTimelinePointPopover.vue'
import {
formatHealthCount,
healthLegend,
healthReliabilityValueClass,
healthStatusMeta,
type HealthTimeline,
type HealthTimelinePoint,
} from '../constants'
const props = defineProps<{
timeline: any
timeline: HealthTimeline
}>()
const healthLegend = [
{ code: '0', label: '未来' },
{ code: '1', label: '无请求' },
{ code: '2', label: '不可达' },
{ code: '3', label: '不稳定' },
{ code: '4', label: '低样本' },
{ code: '5', label: '稳定' },
]
const timelineGrid = useTemplateRef<HTMLElement>('timelineGrid')
const preferredMotion = usePreferredReducedMotion()
const points = computed(() => props.timeline.points)
const points = computed(() => String(props.timeline.points ?? '').split(''))
const pointGroups = computed(() => {
const groups: string[][] = []
for (let index = 0; index < points.value.length; index += 4) {
groups.push(points.value.slice(index, index + 4))
const activePoint = shallowRef<HealthTimelinePoint>()
const activeAnchor = shallowRef<HTMLElement | null>(null)
const popoverOpen = shallowRef(false)
const highlightedPointTime = shallowRef<string>()
let wavedCellIndexes = new Set<number>()
function observedRequests(point: HealthTimelinePoint) {
return (
point.successRequests +
point.failedRequests +
point.cancelledRequests +
point.callerErrorRequests
)
}
function isInteractivePoint(point: HealthTimelinePoint) {
return point.status !== 'future' && observedRequests(point) > 0
}
function isActivePoint(point: HealthTimelinePoint) {
return popoverOpen.value && highlightedPointTime.value === point.time
}
function activatePoint(point: HealthTimelinePoint, pointIndex: number, event: Event) {
if (!(event.currentTarget instanceof HTMLElement)) return
if (!isInteractivePoint(point)) {
closePointPopover()
return
}
return groups
activePoint.value = point
activeAnchor.value = event.currentTarget
popoverOpen.value = true
highlightedPointTime.value = point.time
animatePointWave(pointIndex)
}
function closePointPopover() {
popoverOpen.value = false
resetPointInteraction()
}
function resetPointInteraction() {
highlightedPointTime.value = undefined
activePoint.value = undefined
activeAnchor.value = null
releasePointWave()
}
function timelineButtons() {
return Array.from(
timelineGrid.value?.querySelectorAll<HTMLButtonElement>('[data-health-timeline-point]') ?? [],
)
}
function timelineCell(button?: HTMLButtonElement) {
return button?.querySelector<HTMLElement>('[data-health-timeline-cell]')
}
function animatePointWave(centerIndex: number) {
const buttons = timelineButtons()
const centerButton = buttons[centerIndex]
if (!centerButton) return
const centerRow = centerButton.offsetTop
const nextCellIndexes = new Set<number>()
const cellsByDistance: HTMLElement[][] = [[], [], []]
for (let distance = 0; distance <= 2; distance += 1) {
const candidateIndexes =
distance === 0 ? [centerIndex] : [centerIndex - distance, centerIndex + distance]
for (const index of candidateIndexes) {
const button = buttons[index]
const point = points.value[index]
const cell = timelineCell(button)
if (
!button ||
!point ||
!cell ||
button.offsetTop !== centerRow ||
!isInteractivePoint(point)
) {
continue
}
nextCellIndexes.add(index)
cellsByDistance[distance]?.push(cell)
}
}
const cellsToRelease = [...wavedCellIndexes]
.filter((index) => !nextCellIndexes.has(index))
.map((index) => timelineCell(buttons[index]))
.filter((cell): cell is HTMLElement => Boolean(cell))
if (preferredMotion.value === 'reduce') {
const cells = cellsByDistance.flat()
gsap.set([...cells, ...cellsToRelease], { clearProps: 'transform,willChange' })
wavedCellIndexes = nextCellIndexes
return
}
animateWaveCells(cellsByDistance[0] ?? [], -6, 1.16, 0.26, 'back.out(1.45)')
animateWaveCells(cellsByDistance[1] ?? [], -3.25, 1.08, 0.34, 'power3.out')
animateWaveCells(cellsByDistance[2] ?? [], -1.25, 1.03, 0.42, 'power3.out')
settleWaveCells(cellsToRelease)
wavedCellIndexes = nextCellIndexes
}
function animateWaveCells(
cells: HTMLElement[],
y: number,
scaleY: number,
duration: number,
ease: string,
) {
if (cells.length === 0) return
gsap.set(cells, { willChange: 'transform' })
gsap.to(cells, {
y,
scaleY,
duration,
ease,
overwrite: 'auto',
})
}
function settleWaveCells(cells: HTMLElement[]) {
if (cells.length === 0) return
gsap.to(cells, {
y: 0,
scaleY: 1,
duration: 0.5,
ease: 'power3.out',
overwrite: 'auto',
clearProps: 'transform,willChange',
})
}
function releasePointWave() {
const buttons = timelineButtons()
const cells = [...wavedCellIndexes]
.map((index) => timelineCell(buttons[index]))
.filter((cell): cell is HTMLElement => Boolean(cell))
if (preferredMotion.value === 'reduce') {
gsap.set(cells, { clearProps: 'transform,willChange' })
} else {
settleWaveCells(cells)
}
wavedCellIndexes = new Set<number>()
}
function pointAccessibilityLabel(point: HealthTimelinePoint) {
const eligibleRequests = point.successRequests + point.failedRequests
return `${point.time},${healthStatusMeta[point.status].label},有效请求 ${formatHealthCount(eligibleRequests)},可用性 ${point.reliabilityDisplay}`
}
watch(popoverOpen, (open) => {
if (open) return
resetPointInteraction()
})
function cellClass(point: string) {
if (point === '0') return 'bg-(--cp-disabled-bg) opacity-60'
if (point === '1') return 'bg-(--cp-default-border-hover)'
if (point === '2') return 'bg-(--cp-danger)'
if (point === '3') return 'bg-(--cp-warning)'
if (point === '4') return 'bg-(--cp-normal)'
if (point === '5') return 'bg-(--cp-success)'
return 'bg-(--cp-default-border-hover)'
}
onBeforeUnmount(() => {
const cells = timelineButtons()
.map((button) => timelineCell(button))
.filter((cell): cell is HTMLElement => Boolean(cell))
gsap.killTweensOf(cells)
})
</script>
<template>
@@ -52,41 +214,91 @@ function cellClass(point: string) {
>
<span
v-for="item in healthLegend"
:key="item.code"
:key="item.status"
class="inline-flex h-3.5 items-center gap-1 align-middle leading-none"
>
<span
aria-hidden="true"
class="block size-2 shrink-0 rounded-xs"
:class="cellClass(item.code)"
:class="healthStatusMeta[item.status].cellClass"
/>
<span class="block leading-none">{{ item.label }}</span>
</span>
</div>
<strong
class="shrink-0 font-mono text-sm leading-none font-[760] tabular-nums text-(--cp-success-text)"
>
{{ timeline.reliabilityDisplay }}
</strong>
<div class="flex shrink-0 items-center gap-3">
<strong
class="font-mono text-sm leading-none font-[760] tabular-nums"
:class="healthReliabilityValueClass(timeline.successRequests, timeline.failedRequests)"
>
{{ timeline.reliabilityDisplay }}
</strong>
</div>
</div>
</template>
<template #body>
<div class="mt-4.25">
<div class="grid w-full grid-cols-24 items-end gap-0.75">
<div
v-for="(group, groupIndex) in pointGroups"
:key="groupIndex"
class="grid grid-cols-4 items-end gap-0.5"
>
<i
v-for="(point, pointIndex) in group"
:key="groupIndex * 4 + pointIndex"
class="h-3.5 min-w-0.5 rounded-xs"
:class="cellClass(point)"
/>
</div>
</div>
<BasePopover
v-model="popoverOpen"
trigger="hover"
placement="top"
:offset="12"
width="288px"
:anchor-element="activeAnchor"
:disabled="!activePoint"
animate-position
panel-class="!p-0 text-(--cp-text-primary)"
trigger-class="w-full"
class="block! min-w-0 w-full"
>
<template #trigger>
<div
ref="timelineGrid"
class="grid w-full grid-cols-48 items-end gap-x-0.5 gap-y-1 sm:grid-cols-96"
>
<button
v-for="(point, pointIndex) in points"
:key="point.time"
data-health-timeline-point
type="button"
:aria-disabled="!isInteractivePoint(point)"
:tabindex="isInteractivePoint(point) ? 0 : -1"
:aria-expanded="isActivePoint(point)"
aria-haspopup="dialog"
:aria-label="pointAccessibilityLabel(point)"
class="group relative flex h-5 w-full min-w-0.5 items-center border-0 bg-transparent p-0 outline-none"
:class="isInteractivePoint(point) ? 'cursor-pointer' : 'cursor-default'"
@mouseenter="activatePoint(point, pointIndex, $event)"
@focus="activatePoint(point, pointIndex, $event)"
@blur="closePointPopover"
@click="activatePoint(point, pointIndex, $event)"
>
<span
aria-hidden="true"
data-health-timeline-cell
class="block h-3.5 w-full origin-bottom rounded-xs transition-[filter,box-shadow] duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none group-focus-visible:shadow-[0_0_0_2px_var(--cp-bg-surface),0_0_0_4px_var(--cp-info-border)]"
:class="[
healthStatusMeta[point.status].cellClass,
isActivePoint(point)
? 'brightness-105 shadow-[0_5px_10px_-5px_var(--cp-shadow-sticky)]'
: isInteractivePoint(point)
? 'group-hover:brightness-95'
: undefined,
]"
/>
<span
aria-hidden="true"
class="pointer-events-none absolute bottom-0 left-1/2 h-0.75 w-1.5 -translate-x-1/2 rounded-full bg-(--cp-text-primary) transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:class="
isActivePoint(point) ? 'translate-y-0 opacity-60' : 'translate-y-1 opacity-0'
"
/>
</button>
</div>
</template>
<HealthTimelinePointPopover v-if="activePoint" :point="activePoint" />
</BasePopover>
</div>
</template>
</BaseCard>
@@ -54,14 +54,13 @@ const chartOption = computed<EChartsOption>(() => {
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<EChartsOption>(() => {
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) {
</template>
<template #body>
<div
class="mt-4.75 grid grid-cols-1 gap-5 lg:h-70 lg:grid-cols-[minmax(0,1fr)_minmax(150px,180px)] lg:gap-7.5"
>
<div class="relative h-70 w-full overflow-hidden lg:h-full">
<BaseChart v-if="hasSamples" :option="chartOption" :height="280" />
<BaseEmpty
v-if="!hasSamples"
compact
title="暂无趋势数据"
description="当日暂无请求日志"
class="h-full place-content-center bg-transparent"
/>
</div>
<aside
class="grid h-70 w-full grid-rows-3 rounded-2xl bg-(--cp-bg-subtle) px-5 py-4.5 lg:h-full"
<div class="mt-4.5 grid gap-3.5">
<div
class="grid h-14.25 min-w-0 grid-cols-3 gap-1.5 rounded-xl bg-(--cp-bg-subtle)/45 p-1.5"
>
<div
v-for="item in props.summary"
:key="item.label"
class="grid grid-cols-[minmax(0,1fr)_8px] items-center gap-x-3 py-2"
class="grid min-w-0 grid-cols-[8px_minmax(0,1fr)] items-center gap-x-2 rounded-lg px-2.5 py-2"
>
<span class="grid gap-1.75">
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">{{
item.label
}}</span>
<strong
class="font-mono text-2xl leading-[1.15] font-[760] tabular-nums text-(--cp-text-primary)"
>{{ item.value }}</strong
>
</span>
<i
class="size-2 justify-self-end rounded-full"
class="size-2 rounded-full"
:style="summaryMarkerStyle(item)"
:class="{
'bg-(--cp-info)': item.tone === 'info',
@@ -547,8 +465,30 @@ function handleTrendChange(value: string) {
'bg-(--cp-normal)': item.tone === 'normal',
}"
/>
<span class="grid min-w-0 gap-1">
<span class="truncate text-[10px] leading-none font-[680] text-(--cp-text-secondary)">
{{ item.label }}
</span>
<strong
class="truncate font-mono text-[15px] leading-none font-[760] tabular-nums text-(--cp-text-primary)"
:title="item.value"
>
{{ item.value }}
</strong>
</span>
</div>
</aside>
</div>
<div class="relative h-55 w-full overflow-hidden">
<BaseChart v-if="hasSamples" :option="chartOption" :height="220" />
<BaseEmpty
v-if="!hasSamples"
compact
title="暂无趋势数据"
description="当日暂无请求日志"
class="h-full place-content-center bg-transparent"
/>
</div>
</div>
</template>
</BaseCard>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Binary, Box, CheckCircle2, Monitor, RefreshCw, TriangleAlert } from '@lucide/vue'
import { Box, CheckCircle2, Monitor, RefreshCw, Terminal, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
@@ -62,52 +62,62 @@ const checkedAt = computed(() =>
</script>
<template>
<BaseCard as="article" variant="dashboard" title="上游请求身份" class="min-h-95 w-full">
<BaseCard
as="article"
variant="dashboard"
title="上游请求身份"
body-class="flex min-h-0 flex-1 flex-col"
class="flex min-h-95 w-full flex-col"
>
<template #actions>
<span
class="inline-flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-[12px] leading-none font-[720]"
:class="releaseStatus.tone"
:title="releaseStatus.title"
>
<component :is="releaseStatus.icon" class="size-3.5" />
<component :is="releaseStatus.icon" aria-hidden="true" class="size-3.5" />
{{ releaseStatus.label }}
</span>
</template>
<template #body>
<BaseEmpty v-if="!profile" compact title="暂无请求身份" class="h-67 place-content-center" />
<BaseEmpty
v-if="!profile"
compact
title="暂无请求身份"
class="mt-5 min-h-[287px] flex-1 place-content-center"
/>
<div
v-else
class="mt-5 grid gap-3 max-[767px]:pl-5 sm:h-[287px] sm:grid-rows-[minmax(0,1fr)_24px]"
>
<div v-else class="mt-5 grid flex-1 gap-3 sm:grid-rows-[minmax(0,1fr)_24px]">
<section
aria-label="请求身份组成"
class="grid min-w-0 content-between gap-6 rounded-[14px] bg-(--cp-bg-subtle) px-5 py-5.5 sm:h-full sm:grid-rows-[auto_minmax(0,1fr)_auto] sm:px-6 sm:py-5"
>
<div class="flex min-w-0 items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-2 text-(--cp-success-text)">
<div class="flex min-w-0 items-center">
<div class="flex min-w-0 items-center gap-2 text-(--cp-text-primary)">
<span
class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-(--cp-success-bg)"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-(--cp-bg-muted)"
>
<Box class="size-3.75 text-(--cp-success)" />
<Box aria-hidden="true" class="size-3.75 text-(--cp-text-secondary)" />
</span>
<span class="truncate text-[11px] leading-none font-[760]">Codex Desktop</span>
</div>
<span
class="shrink-0 font-mono text-[10px] leading-none font-[650] tabular-nums text-(--cp-text-muted)"
>
Build {{ profile.desktopBuild }}
</span>
</div>
<div class="grid min-h-0 content-center">
<strong
class="block max-w-full wrap-break-word font-mono text-[27px] leading-[1.05] font-[790] tabular-nums text-(--cp-text-primary)"
:title="profile.desktopVersion"
>
{{ profile.desktopVersion }}
</strong>
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1.5">
<strong
class="block max-w-full wrap-break-word font-mono text-[27px] leading-[1.05] font-[790] tabular-nums text-(--cp-text-primary)"
:title="profile.desktopVersion"
>
{{ profile.desktopVersion }}
</strong>
<span
class="shrink-0 font-mono text-[10px] leading-none font-[650] tabular-nums text-(--cp-text-muted)"
>
Build {{ profile.desktopBuild }}
</span>
</div>
</div>
<dl class="m-0 grid min-w-0 gap-5 sm:grid-cols-[0.72fr_1.28fr] sm:gap-7">
@@ -115,7 +125,7 @@ const checkedAt = computed(() =>
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-[720] text-(--cp-text-muted)"
>
<Binary class="size-3.25 text-(--cp-info)" />
<Terminal aria-hidden="true" class="size-3.25 text-(--cp-info)" />
Codex Core
</dt>
<dd
@@ -130,7 +140,7 @@ const checkedAt = computed(() =>
<dt
class="flex items-center gap-1.5 text-[10px] leading-none font-[720] text-(--cp-text-muted)"
>
<Monitor class="size-3.25 text-(--cp-normal)" />
<Monitor aria-hidden="true" class="size-3.25 text-(--cp-normal)" />
模拟运行环境
</dt>
<dd
@@ -148,9 +148,14 @@ export function useDashboard(): any {
},
healthTimeline: {
title: '请求健康时间线',
description: '请求可靠性',
description: '有效请求可用性',
reliabilityDisplay: '-',
points: '',
status: 'no_data',
successRequests: 0,
failedRequests: 0,
cancelledRequests: 0,
callerErrorRequests: 0,
points: [],
},
accountUsage: [],
wireProfile: null,
+86
View File
@@ -0,0 +1,86 @@
export type HealthStatus =
'future' | 'no_data' | 'unavailable' | 'unstable' | 'low_sample' | 'stable'
export interface HealthTimelinePoint {
time: string
status: HealthStatus
reliabilityDisplay: string
successRequests: number
failedRequests: number
cancelledRequests: number
callerErrorRequests: number
}
export interface HealthTimeline {
title: string
description: string
reliabilityDisplay: string
status: HealthStatus
successRequests: number
failedRequests: number
cancelledRequests: number
callerErrorRequests: number
points: HealthTimelinePoint[]
}
interface HealthStatusMeta {
label: string
cellClass: string
badgeClass: string
}
export const healthLegend = [
{ status: 'no_data', label: '无有效样本' },
{ status: 'unavailable', label: '不可达' },
{ status: 'unstable', label: '不稳定' },
{ status: 'low_sample', label: '低样本' },
{ status: 'stable', label: '稳定' },
] satisfies { status: HealthStatus; label: string }[]
export const healthStatusMeta: Record<HealthStatus, HealthStatusMeta> = {
future: {
label: '未来',
cellClass: 'bg-(--cp-disabled-bg) opacity-60',
badgeClass: 'bg-(--cp-bg-muted) text-(--cp-text-muted)',
},
no_data: {
label: '无有效样本',
cellClass: 'bg-(--cp-default-border-hover)',
badgeClass: 'bg-(--cp-bg-muted) text-(--cp-text-secondary)',
},
unavailable: {
label: '不可达',
cellClass: 'bg-(--cp-danger)',
badgeClass: 'bg-(--cp-danger-bg) text-(--cp-danger-text)',
},
unstable: {
label: '不稳定',
cellClass: 'bg-(--cp-warning)',
badgeClass: 'bg-(--cp-warning-bg) text-(--cp-warning-text)',
},
low_sample: {
label: '低样本',
cellClass: 'bg-(--cp-normal)',
badgeClass: 'bg-(--cp-normal-bg) text-(--cp-normal-text)',
},
stable: {
label: '稳定',
cellClass: 'bg-(--cp-success)',
badgeClass: 'bg-(--cp-success-bg) text-(--cp-success-text)',
},
}
export function healthReliabilityValueClass(successRequests: number, failedRequests: number) {
const eligibleRequests = Math.max(0, successRequests) + Math.max(0, failedRequests)
if (eligibleRequests === 0) return 'text-(--cp-text-muted)'
const reliability = (Math.max(0, successRequests) / eligibleRequests) * 100
if (reliability >= 99.5) return 'text-(--cp-success-text)'
if (reliability >= 98) return 'text-(--cp-normal-text)'
if (reliability >= 95) return 'text-(--cp-warning-text)'
return 'text-(--cp-danger-text)'
}
export function formatHealthCount(value: number) {
return Math.max(0, value).toLocaleString('zh-CN')
}
@@ -37,7 +37,7 @@ function showDetail(record: any) {
</script>
<template>
<div class="grid min-h-[520px] flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
<div class="grid min-h-130 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
<div
class="flex w-full flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center"
role="group"
@@ -30,7 +30,13 @@ const dimensionOptions = [
]
const diagnosticColumns = [
{ key: 'nameDisplay', label: '维度', width: '220px', ellipsis: false },
{
key: 'nameDisplay',
label: '维度',
width: '180px',
fixed: false as const,
ellipsis: false,
},
{
key: 'requestCount',
label: '请求 / 占比',
@@ -57,11 +63,14 @@ const diagnosticColumns = [
},
]
const dimensionLabel = computed(
const selectedDimensionLabel = computed(
() => dimensionOptions.find((option) => option.value === dimension.value)?.label ?? '维度',
)
const isStale = computed(() => props.diagnostics.dimension !== dimension.value)
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
const resultDimensionLabel = computed(
() => dimensionOptions.find((option) => option.value === resultDimension.value)?.label ?? '维度',
)
const sortedItems = computed(() =>
[...props.diagnostics.items].sort(
@@ -76,13 +85,11 @@ const displayItems = computed(() =>
})),
)
const tableRows = computed(() => (isStale.value ? [] : displayItems.value))
function diagnosticNameDisplay(name: string) {
const raw = name.trim() || '未知'
const full =
dimension.value === 'transport' ? ({ websocket: 'WS', http_sse: 'SSE' }[raw] ?? raw) : raw
if (dimension.value !== 'model' && dimension.value !== 'account') {
resultDimension.value === 'transport' ? ({ websocket: 'WS', http_sse: 'SSE' }[raw] ?? raw) : raw
if (resultDimension.value !== 'model' && resultDimension.value !== 'account') {
return { primary: full, secondary: '', full }
}
@@ -100,7 +107,7 @@ function diagnosticNameDisplay(name: string) {
as="article"
:padded="false"
title="热点诊断"
:description="`按${dimensionLabel}定位错误、慢请求与费用热点`"
:description="`按${selectedDimensionLabel}定位错误、慢请求与费用热点`"
header-collapse-at="lg"
header-class="px-5 pt-4"
body-class="min-h-0 min-w-0 px-5 pt-3"
@@ -110,18 +117,17 @@ function diagnosticNameDisplay(name: string) {
<BaseSegmented
v-model="dimension"
:options="dimensionOptions"
:disabled="loading"
class="w-full min-w-0 lg:w-80"
/>
</template>
<template #body>
<BaseTable
:key="dimension"
:key="resultDimension"
class="min-h-0 w-full"
:columns="diagnosticColumns"
:rows="tableRows"
:loading="loading || isStale"
:rows="displayItems"
:loading="loading"
compact
row-key="name"
empty-text="暂无诊断数据"
@@ -129,7 +135,7 @@ function diagnosticNameDisplay(name: string) {
min-width="640px"
>
<template #header-nameDisplay>
{{ dimensionLabel }}
{{ resultDimensionLabel }}
</template>
<template #nameDisplay="{ row }">
@@ -144,7 +150,7 @@ function diagnosticNameDisplay(name: string) {
class="flex min-w-0 items-center gap-1.25 text-(--cp-text-secondary)"
>
<CornerDownRight
v-if="dimension !== 'account'"
v-if="resultDimension !== 'account'"
class="size-3.25 shrink-0 text-(--cp-info)"
stroke-width="2.4"
/>
@@ -9,11 +9,9 @@ withDefaults(
overview: any
diagnostics: any
loading?: boolean
diagnosticLoading?: boolean
}>(),
{
loading: false,
diagnosticLoading: false,
},
)
@@ -34,7 +32,7 @@ const diagnosticDimension = defineModel('diagnosticDimension', {
<UsageDiagnosticCard
v-model:dimension="diagnosticDimension"
:diagnostics="diagnostics"
:loading="loading || diagnosticLoading"
:loading="loading"
/>
<UsagePerformanceCard :performance="overview.performance" :loading="loading" />
@@ -35,7 +35,6 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
})
const refreshingList = shallowRef(false)
const diagnosticDimension = shallowRef('model')
const diagnosticLoading = shallowRef(false)
let loadRequestId = 0
let diagnosticRequestId = 0
const scopedParams = () => ({ ...options.timeRangeParams.value })
@@ -50,7 +49,6 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
if (scope === 'all') {
analyticsLoading.value = true
diagnosticRequestId += 1
diagnosticLoading.value = false
}
const globalParams = scopedParams()
@@ -132,7 +130,6 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
const dimension = diagnosticDimension.value
const params = scopedParams()
try {
diagnosticLoading.value = true
const diagnostics = await getUsageRecordInsightsDiagnostics({
...params,
dimension,
@@ -144,10 +141,6 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
}
} catch (error: any) {
toast.error(error.message || '加载失败')
} finally {
if (requestId === diagnosticRequestId) {
diagnosticLoading.value = false
}
}
}
@@ -178,7 +171,6 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
insights,
refreshingList,
diagnosticDimension,
diagnosticLoading,
loadUsageRecords,
refreshUsageRecords,
}
-2
View File
@@ -57,7 +57,6 @@ const {
insights,
refreshingList,
diagnosticDimension,
diagnosticLoading,
loadUsageRecords,
refreshUsageRecords,
} = useUsageRecordsTable({
@@ -105,7 +104,6 @@ watch(timeRange, () => {
:overview="insights.overview"
:diagnostics="insights.diagnostics"
:loading="analyticsLoading"
:diagnostic-loading="diagnosticLoading"
/>
<BaseCard