mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub(crate) mod account_usage;
|
||||
mod dashboard;
|
||||
pub(crate) mod ops;
|
||||
pub(crate) mod rebuild;
|
||||
pub(crate) mod usage;
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
mod insights;
|
||||
mod store;
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user