feat(usage): unify request records with status filters (#447)

* feat(usage): unify request records with status filters

Co-authored-by: Codex <noreply@openai.com>

* fix(usage): wrap record filters on narrow screens

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-16 16:19:36 +08:00
committed by GitHub
co-authored by Codex Zyy
parent 338cde1505
commit bd9b017ed3
28 changed files with 287 additions and 701 deletions
@@ -172,9 +172,19 @@ pub struct UsageFilter {
pub search: Option<String>,
}
/// 请求列表的结果筛选,不改变汇总和计量口径
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum UsageRecordStatus {
#[default]
Success,
Error,
All,
}
/// 用量记录分页查询
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageQuery {
pub status: UsageRecordStatus,
pub range: TimeRange,
pub filter: UsageFilter,
pub current_page: u32,
@@ -653,6 +663,7 @@ pub struct DashboardObservation {
/// 使用记录表格的窄读模型
#[derive(Debug, Clone, PartialEq)]
pub struct UsageListRecord {
pub outcome: RequestOutcome,
pub client_api_key_name: Option<String>,
pub id: String,
pub endpoint: String,
@@ -845,7 +856,7 @@ pub struct UsageAttempt {
pub occurred_at: DateTime<Utc>,
}
/// 一条请求及其全部尝试
/// 一条请求及其保留的尝试与诊断轨迹
#[derive(Debug, Clone, PartialEq)]
pub struct UsageDetail {
pub trace: Option<serde_json::Value>,
@@ -225,6 +225,7 @@ impl KeyUsageService for DefaultKeyUsageService {
KeyUsageRecordKind::Success => KeyUsageRecords::Success(
self.observations
.list_usage_records(UsageQuery {
status: Default::default(),
range: query.usage.range,
filter: usage_filter(&id, query.usage.model),
current_page: query.current_page,
@@ -1202,6 +1202,7 @@ fn observation_range(end: DateTime<Utc>) -> TimeRange {
fn usage_query(now: DateTime<Utc>) -> UsageQuery {
UsageQuery {
status: Default::default(),
range: observation_range(now),
filter: UsageFilter::default(),
current_page: 1,
@@ -1216,6 +1217,7 @@ fn total_record(
now: DateTime<Utc>,
) -> UsageListRecord {
UsageListRecord {
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: id.to_owned(),
endpoint: "/v1/responses".to_owned(),
@@ -262,6 +262,7 @@ pub(crate) fn usage_list_record_view(
.clone()
.or_else(|| record.requested_model_id.clone());
UsageListRecordView {
logical_outcome: record.outcome.as_str().to_owned(),
client_api_key_name: record.client_api_key_name,
id: record.id,
provider: record.provider_kind,
@@ -29,6 +29,7 @@ impl DashboardQuery {
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UsageQuery {
pub status: Option<String>,
pub current_page: Option<u32>,
pub page_size: Option<u16>,
pub kind: Option<String>,
@@ -330,6 +331,12 @@ pub(crate) fn usage_command(
let page_size_value =
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
Ok(domain::UsageQuery {
status: match query.status.as_deref() {
None | Some("success") => domain::UsageRecordStatus::Success,
Some("error") => domain::UsageRecordStatus::Error,
Some("all") => domain::UsageRecordStatus::All,
Some(_) => return Err(WireValidationError::new("status")),
},
range: usage_range(
query.start_time.as_deref(),
query.end_time.as_deref(),
@@ -86,6 +86,7 @@ pub struct ImageBillingView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageListRecordView {
pub logical_outcome: String,
pub client_api_key_name: Option<String>,
pub id: String,
pub provider: Option<String>,
@@ -801,6 +801,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.lock()
.expect("usage records")
.push(UsageListRecord {
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: "request_endpoint".to_owned(),
endpoint: "/v1/responses".to_owned(),
@@ -1307,3 +1308,62 @@ pub(super) fn ops_error_record() -> gateway_admin::model::observability::OpsErro
stable_sort_id: "model_request:req_err".to_owned(),
}
}
#[tokio::test]
async fn usage_status_query_accepts_three_modes_and_rejects_unknown_values() {
use crate::admin::{AdminTestFixture, AdminTestState};
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (status, expected) in [
("success", StatusCode::OK),
("error", StatusCode::OK),
("all", StatusCode::OK),
("failed", StatusCode::BAD_REQUEST),
] {
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri(format!("/api/admin/usage/records?status={status}"))
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "usage_status_fixture")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let actual = response.status();
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
assert_eq!(
actual,
expected,
"{status}: {}",
String::from_utf8_lossy(&body)
);
if expected == StatusCode::OK {
use gateway_admin::model::observability::UsageRecordStatus;
let status_value = match status {
"success" => UsageRecordStatus::Success,
"error" => UsageRecordStatus::Error,
_ => UsageRecordStatus::All,
};
assert_eq!(
fixture
.observations
.lock()
.unwrap()
.records
.last()
.unwrap()
.status,
status_value
);
}
}
}
@@ -93,6 +93,7 @@ pub(super) async fn fixture() -> AdminTestFixture {
fn usage_record() -> UsageListRecord {
UsageListRecord {
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: "req-visible".to_owned(),
endpoint: "/v1/responses".to_owned(),
@@ -173,6 +173,7 @@ impl PgObservabilityRepository {
let account_usage_query = ProviderAccountUsageQuery::recent(range, query.account_limit)?
.with_hourly_request_buckets()?;
let recent_query = UsageRecordQuery {
status: Default::default(),
range,
filter: store_usage_filter(query.recent_request_filter),
current_page: 1,
@@ -41,6 +41,7 @@ pub(crate) fn store_usage_query(
query: admin_observability::UsageQuery,
) -> AdminStoreResult<UsageRecordQuery> {
Ok(UsageRecordQuery {
status: query.status,
range: store_range(query.range).map_err(observability_error)?,
filter: store_usage_filter(query.filter),
current_page: query.current_page,
@@ -302,6 +303,8 @@ pub(crate) fn usage_list_record_from_row(
cost_currency.as_deref(),
)?;
Ok(UsageListRecord {
outcome: admin_observability::RequestOutcome::new(get::<String>(row, "outcome")?)
.map_err(|_| invalid("invalid request outcome"))?,
client_api_key_name: get(row, "client_api_key_name")?,
billing,
id: get(row, "id")?,
@@ -94,6 +94,7 @@ impl UsageRecordFilter {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageRecordQuery {
pub status: admin_observability::UsageRecordStatus,
pub range: ObservabilityRange,
pub filter: UsageRecordFilter,
pub current_page: u32,
@@ -113,7 +113,7 @@ pub(crate) fn literal_prefix_pattern(value: &str) -> String {
}
pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
"select mr.id, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id,
"select mr.id, mr.outcome, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id,
mr.provider_kind, mr.provider_account_ref,
mr.provider_account_name_snapshot as provider_account_name,
mr.provider_account_email_snapshot as provider_account_email,
@@ -176,22 +176,24 @@ pub(crate) async fn list_usage_records(
let items = list_usage_record_items(&mut *snapshot.tx, &query, None).await?;
let total = if items.len() < usize::from(query.page_size.get()) {
items.len() as u64
} else if let Some(counts) = snapshot
.completed_request_counts(query.range, &query.filter)
.await?
} else if query.status == admin_observability::UsageRecordStatus::Success
&& let Some(counts) = snapshot
.completed_request_counts(query.range, &query.filter)
.await?
{
counts.values().try_fold(0_u64, |sum, count| {
sum.checked_add(*count)
.ok_or_else(|| invalid("usage count overflows u64"))
})?
} else {
count_usage_records(&mut *snapshot.tx, query.range, &query.filter).await?
count_usage_records(&mut *snapshot.tx, query.range, &query.filter, query.status).await?
};
(total, items)
} else {
let (total, page) = if let Some(counts) = snapshot
.completed_request_counts(query.range, &query.filter)
.await?
let (total, page) = if query.status == admin_observability::UsageRecordStatus::Success
&& let Some(counts) = snapshot
.completed_request_counts(query.range, &query.filter)
.await?
{
time_page_from_segments(
counts.into_iter().rev(),
@@ -260,7 +262,7 @@ async fn list_usage_record_items_at<'e, E: sqlx::Executor<'e, Database = Postgre
statement.push_bind(page.range.start);
statement.push(" and mr.started_at < ");
statement.push_bind(page.range.end);
push_completed_usage_fact_filter(&mut statement, "mr");
push_usage_record_status(&mut statement, query.status);
push_usage_filter(&mut statement, &query.filter, "mr");
statement.push(if ascending {
" order by mr.started_at, mr.id limit "
@@ -363,7 +365,7 @@ async fn usage_time_page(
statement.push_bind(query.range.start);
statement.push(" and mr.started_at < ");
statement.push_bind(query.range.end);
push_completed_usage_fact_filter(&mut statement, "mr");
push_usage_record_status(&mut statement, query.status);
push_usage_filter(&mut statement, &query.filter, "mr");
statement.push(" group by bucket_start order by bucket_start desc");
let rows = statement
@@ -378,6 +380,7 @@ pub(crate) async fn count_usage_records<'e, E: sqlx::Executor<'e, Database = Pos
executor: E,
range: ObservabilityRange,
filter: &UsageRecordFilter,
status: admin_observability::UsageRecordStatus,
) -> StoreResult<u64> {
let mut statement = QueryBuilder::<Postgres>::new(
"select count(*)::bigint from model_request_observations mr where mr.started_at >= ",
@@ -385,7 +388,7 @@ pub(crate) async fn count_usage_records<'e, E: sqlx::Executor<'e, Database = Pos
statement.push_bind(range.start);
statement.push(" and mr.started_at < ");
statement.push_bind(range.end);
push_completed_usage_fact_filter(&mut statement, "mr");
push_usage_record_status(&mut statement, status);
push_usage_filter(&mut statement, filter, "mr");
let total = statement
.build_query_scalar::<i64>()
@@ -558,3 +561,23 @@ pub(crate) fn push_diagnostic_dimension_filter(
| DiagnosticDimension::Transport => {}
}
}
fn push_usage_record_status(
statement: &mut QueryBuilder<Postgres>,
status: admin_observability::UsageRecordStatus,
) {
use admin_observability::UsageRecordStatus;
match status {
UsageRecordStatus::Success => push_completed_usage_fact_filter(statement, "mr"),
UsageRecordStatus::Error => {
statement.push(" and mr.outcome in ('failed', 'incomplete')");
}
UsageRecordStatus::All => {
// 成功沿用用量边界,未成功记录保留真实生命周期,不把取消与执行中标为错误
statement
.push(" and (")
.push(completed_usage_fact_predicate("mr"))
.push(" or mr.outcome <> 'succeeded')");
}
}
}
@@ -458,6 +458,7 @@ async fn core_adapter_should_persist_calculated_cost_exactly() {
);
let page = observation
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range: ObservabilityRange::new(
Utc::now() - Duration::hours(1),
Utc::now() + Duration::hours(1),
@@ -2070,6 +2071,7 @@ async fn zero_attempt_failure_does_not_enter_successful_usage_or_cost_aggregates
assert_eq!(failed.attempts, AttemptMetrics::default());
let page = repository
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range: zero_attempt_range(&request),
filter: filter.clone(),
current_page: 1,
@@ -125,6 +125,7 @@ async fn output_throughput_uses_official_response_duration_without_local_fallbac
super::drain_analytics(&database.pool).await;
let records = repository
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter::default(),
current_page: 1,
@@ -235,6 +236,7 @@ async fn observability_preserves_and_filters_opaque_response_ids() {
super::drain_analytics(&database.pool).await;
let records = repository
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter {
response_id: Some(response_id.clone()),
@@ -279,6 +281,7 @@ async fn usage_page_should_always_return_total() {
super::drain_analytics(&database.pool).await;
let page = observability_repository(&database.pool)
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter::default(),
current_page: 1,
@@ -351,6 +354,7 @@ async fn usage_list_and_diagnostics_preserve_account_snapshots_by_id() {
super::drain_analytics(&database.pool).await;
let page = store
.list_usage_records(admin_observability::UsageQuery {
status: Default::default(),
range,
filter: admin_observability::UsageFilter::default(),
current_page: 1,
@@ -440,6 +444,7 @@ async fn usage_list_and_diagnostics_preserve_account_snapshots_by_id() {
super::drain_analytics(&database.pool).await;
let page = store
.list_usage_records(admin_observability::UsageQuery {
status: Default::default(),
range,
filter: admin_observability::UsageFilter {
provider_account_ref: Some("acct_team".to_owned()),
@@ -502,6 +507,7 @@ async fn usage_search_should_match_literal_prefix_instead_of_substring() {
super::drain_analytics(&database.pool).await;
let page = observability_repository(&database.pool)
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter {
search: Some("rimary".to_owned()),
@@ -549,6 +555,7 @@ async fn usage_search_should_ignore_request_ids_and_preserve_exact_filters() {
] {
let page = observability_repository(&database.pool)
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter,
current_page: 1,
@@ -686,6 +693,7 @@ async fn assert_usage_search_ids(
) {
let page = observability_repository(pool)
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter {
search: Some(search.to_owned()),
@@ -754,6 +762,7 @@ async fn usage_and_error_search_match_key_names_instead_of_credentials() {
super::drain_analytics(&database.pool).await;
let page = repository
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter {
search: Some(search.to_owned()),
@@ -813,6 +822,7 @@ async fn usage_and_error_search_match_key_names_instead_of_credentials() {
super::drain_analytics(&database.pool).await;
let page = repository
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter::default(),
current_page: 1,
@@ -1696,6 +1706,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
super::drain_analytics(&database.pool).await;
let first_page = store
.list_usage_records(admin_observability::UsageQuery {
status: Default::default(),
range,
filter: admin_observability::UsageFilter::default(),
current_page: 1,
@@ -1715,6 +1726,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
super::drain_analytics(&database.pool).await;
let deep_page = store
.list_usage_records(admin_observability::UsageQuery {
status: Default::default(),
range,
filter: admin_observability::UsageFilter::default(),
current_page: 129,
@@ -1730,6 +1742,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
super::drain_analytics(&database.pool).await;
let filtered = store
.list_usage_records(admin_observability::UsageQuery {
status: Default::default(),
range,
filter: admin_observability::UsageFilter {
client_api_key_ref: Some("key_observe".to_owned()),
@@ -1758,6 +1771,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
super::drain_analytics(&database.pool).await;
let other_outcome = store
.list_usage_records(admin_observability::UsageQuery {
status: Default::default(),
range,
filter: admin_observability::UsageFilter {
outcome: Some(
@@ -2182,6 +2196,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
super::drain_analytics(&database.pool).await;
let usage_page = repository
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter {
provider_account_ref: Some("acct_observe".to_owned()),
@@ -144,6 +144,7 @@ async fn quota_forecast_excludes_openai_prewarm_but_preserves_inference_and_audi
assert_eq!(account_usage[0].cost_coverage.unavailable_count, 0);
let records = repository
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter::default(),
current_page: 1,
@@ -1288,6 +1288,7 @@ async fn account_snapshot(
fn usage_query(started_at: DateTime<Utc>, filter: UsageRecordFilter) -> UsageRecordQuery {
UsageRecordQuery {
status: Default::default(),
range: range_around(started_at),
filter,
current_page: 1,
@@ -62,6 +62,7 @@ async fn projected_page_counts_correct_pending_moves_deletions_and_model_or() {
for current_page in [1, 3, 6, 8] {
let page = repository
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter {
client_api_key_ref: Some("key_analytics".to_owned()),
@@ -125,6 +126,7 @@ async fn usage_pages_preserve_order_and_exact_totals_from_both_ends() {
for page in 1..=5 {
let result = repository
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter {
client_api_key_ref: key.map(str::to_owned),
@@ -190,6 +192,7 @@ async fn usage_pages_cross_minutes_and_keep_exact_counts_after_source_changes()
for current_page in 1..=6 {
let page = repository
.list_usage_records(UsageRecordQuery {
status: Default::default(),
range,
filter: UsageRecordFilter {
client_api_key_ref: Some("key_analytics".to_owned()),
@@ -280,3 +283,105 @@ async fn ops_pages_merge_sources_across_minutes_with_stable_ties() {
}
database.close().await;
}
#[tokio::test]
async fn request_status_pages_share_one_request_timeline() {
use gateway_admin::{
model::{diagnostics::DiagnosticTarget, observability::UsageRecordStatus},
ports::store::ObservabilityStore,
};
let Some(database) = TestDatabase::create("usage_status_pages").await else {
return;
};
let start = chrono::DateTime::parse_from_rfc3339("2026-09-01T10:00:00Z")
.unwrap()
.with_timezone(&Utc);
// 同时刻请求按 ID 稳定排列;成功请求的中间错误不能额外占据列表行
for (id, outcome) in [
("status_a", "succeeded"),
("status_b", "failed"),
("status_c", "incomplete"),
("status_d", "cancelled"),
("status_e", "running"),
("status_f", "succeeded"),
] {
seed(&database.pool, id, start, &[]).await;
sqlx::query("update model_requests set outcome=$2, provider_kind='openai', completed_at=case when $2='running' then null else completed_at end, input_tokens=case when $2='succeeded' then input_tokens else null end, output_tokens=case when $2='succeeded' then output_tokens else null end, total_tokens=case when $2='succeeded' then total_tokens else null end where id=$1")
.bind(id).bind(outcome).execute(&database.pool).await.unwrap();
}
sqlx::query(
"update model_requests set requested_model_id='model_status_b' where id='status_b'",
)
.execute(&database.pool)
.await
.unwrap();
for id in ["event_a", "event_b"] {
sqlx::query("insert into ops_events(id,model_request_id,attempt_index,level,component,operation,failure_kind,message,created_at) values($1,'status_a',1,'error','provider','request','upstream','retry evidence',$2)")
.bind(id).bind(start).execute(&database.pool).await.unwrap();
}
sqlx::query("insert into ops_events(id,level,component,operation,failure_kind,message,created_at) values('standalone','error','system','refresh','upstream','standalone error',$1)").bind(start).execute(&database.pool).await.unwrap();
super::drain_analytics(&database.pool).await;
let repo = observability_repository(&database.pool);
let range =
ObservabilityRange::new(start - TimeDelta::minutes(1), start + TimeDelta::minutes(5))
.unwrap();
for (status, expected) in [
(UsageRecordStatus::Success, vec!["status_f", "status_a"]),
(UsageRecordStatus::Error, vec!["status_c", "status_b"]),
(
UsageRecordStatus::All,
vec![
"status_f", "status_e", "status_d", "status_c", "status_b", "status_a",
],
),
] {
let mut actual = Vec::new();
for current_page in 1..=4 {
let page = repo
.list_usage_records(UsageRecordQuery {
status,
range,
filter: UsageRecordFilter::default(),
current_page,
page_size: ObservabilityPageSize::new(2).unwrap(),
})
.await
.unwrap();
assert_eq!(page.total, expected.len() as u64);
for item in page.items {
if matches!(
item.outcome.as_str(),
"failed" | "incomplete" | "running" | "cancelled"
) {
assert_eq!(item.total_tokens, None);
}
actual.push(item.id);
}
}
assert_eq!(actual, expected, "{status:?}");
}
let page = repo
.list_usage_records(UsageRecordQuery {
status: UsageRecordStatus::Error,
range,
filter: UsageRecordFilter {
search: Some("model_status_b".into()),
provider_kind: Some("openai".into()),
..Default::default()
},
current_page: 1,
page_size: ObservabilityPageSize::new(10).unwrap(),
})
.await
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].id, "status_b");
// 诊断包仍可按请求 ID 关联中间事件,不受列表时间范围或最终结果限制
let errors = super::admin_observability_store(&database.pool)
.diagnostic_errors(&DiagnosticTarget::Request("status_a".into()), 100)
.await
.unwrap();
assert_eq!(errors.len(), 2);
database.close().await;
}
+6
View File
@@ -1890,6 +1890,12 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚
request/response/upstream ID、outcome 与搜索文本。`search` 仅供明细及错误列表使用;
汇总、洞察和诊断不接受搜索参数,管理观测接口不接受路由分组筛选。页面搜索不改变顶部统计范围
请求列表的 `status` 可取 `success`、`error`、`all`,默认 `success`;未知值返回 `400`。
`success` 沿用成功用量记录范围,`error` 包含最终 `failed`、`incomplete` 的请求,
`all` 合并成功用量记录及全部非成功请求,包括执行中和已取消请求。每个请求只返回一行,
重试中间错误不改变请求最终分类;`logicalOutcome` 保留真实结果。状态筛选只影响明细列表,
不改变顶部统计,独立运维事件仍通过 `/api/admin/operations/errors` 查询
诊断 `dimension` 可取 `model`、`account`、
`apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项,
同请求量按维度标识稳定排列
+4 -83
View File
@@ -81,7 +81,10 @@ export interface UsageRecordMetadata {
[key: string]: unknown
}
export type UsageRecordStatus = 'success' | 'error' | 'all'
export interface UsageListRecord {
logicalOutcome: string
clientApiKeyName: string | null
id: string
provider: string | null
@@ -218,77 +221,6 @@ export interface UsageRecordsResponse {
total: number
}
export interface OpsErrorMetadata {
source: string
component: string
attemptId: string | null
accountLabel: string | null
continuationAffinityHash: string | null
continuationPreviousResponseIdHash: string | null
continuationUnavailableReason: string | null
upstreamConnectionId: string | null
upstreamConnectionExitReason: string | null
upstreamConnectionAgeMs: number | null
upstreamConnectionIdleMs: number | null
recoveryRequestId: string | null
recoveredAt: string | null
recoveryAttemptCount: number
recoveryRetryDelayMs: number | null
recoveryTotalLatencyMs: number | null
}
export interface OpsError {
clientApiKeyName: string | null
id: string
requestId: string | null
clientApiKeyId: string | null
kind: string
operation: string
protocol: string | null
clientTransport: string | null
provider: string | null
authenticationKind: string | null
accountId: string | null
accountName: string | null
accountEmail: string | null
accountPlanType: string | null
accountPlanTypeDisplay: string | null
route: string
model: string | null
requestedModel: string | null
upstreamModel: string | null
serviceTier: string | null
clientStatusCode: number | null
upstreamStatusCode: number | null
transport: string | null
attemptIndex: number | null
failureClass: string
upstreamSendState: string | null
providerErrorCode: string | null
responseId: string | null
upstreamRequestId: string | null
latencyMs: number | null
clientIp: string | null
userAgent: string | null
reasoningEffort: string | null
reasoningPreset: string | null
requestKind: string | null
subagentKind: string | null
compact: boolean | null
message: string
errorDetails: string | null
metadata: OpsErrorMetadata
createdAt: string
createdAtDisplay: string
}
export interface OpsErrorsResponse {
items: OpsError[]
currentPage: number
pageSize: number
total: number
}
export interface UsageSummaryResponse {
analytics: AnalyticsMetadata
totalRequests: string
@@ -454,9 +386,7 @@ interface PageQuery {
pageSize: number
}
type UsagePageQuery = UsageRangeQuery & PageQuery & { search?: string }
type OpsErrorPageQuery = UsagePageQuery
type UsagePageQuery = UsageRangeQuery & PageQuery & { search?: string, status?: UsageRecordStatus }
interface UsageDetailQuery {
id: string
@@ -473,15 +403,6 @@ export function getUsageRecords(data: UsagePageQuery, options: RequestOptions =
})
}
export function getOpsErrors(data: OpsErrorPageQuery, options: RequestOptions = {}) {
return request<OpsErrorsResponse>({
url: '/api/admin/operations/errors',
method: 'GET',
params: data,
...options,
})
}
export function getUsageRecordDetail(data: UsageDetailQuery, options: RequestOptions = {}) {
return request<UsageRecordDetail>({
url: '/api/admin/usage/records/detail',
@@ -112,6 +112,7 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord
return {
id: options.id,
logicalOutcome: 'succeeded',
clientApiKeyName: '演示 Key',
provider: options.provider,
authenticationKind: options.authenticationKind,
@@ -1,188 +0,0 @@
<script setup lang="ts">
import type { OpsError } from '@/api'
import { ZButton, ZDialog } from '@codex-proxy/ui'
import { computed } from 'vue'
import { failureClassText } from '../utils/opsErrorPresentation'
import DiagnosticsDownloadButton from './DiagnosticsDownloadButton.vue'
import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
import UsageDetailFieldGrid from './UsageDetailFieldGrid.vue'
interface DetailField {
label: string
value: unknown
mono?: boolean
wrap?: boolean
fullWidth?: boolean
}
const props = defineProps<{
record: OpsError | null
}>()
const open = defineModel<boolean>({ default: false })
const panelClass = 'min-w-0 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5'
const panelTitleClass = 'm-0 text-cp-sm leading-none font-heavy text-cp-text-secondary'
const accountLabel = computed(() => props.record?.accountEmail
|| props.record?.accountName
|| props.record?.metadata.accountLabel
|| props.record?.accountId)
const errorFields = computed(() => visibleFields([
{
label: '处理状态',
value: props.record?.metadata.recoveredAt ? '已自动恢复' : null,
},
{ label: 'Provider 错误码', value: props.record?.providerErrorCode, mono: true },
{ label: '失败分类', value: failureClassText(props.record?.failureClass) },
{ label: '分类代码', value: props.record?.failureClass, mono: true },
{ label: '发送状态', value: props.record?.upstreamSendState, mono: true },
{ label: '客户端状态', value: props.record?.clientStatusCode, mono: true },
{ label: '上游状态', value: props.record?.upstreamStatusCode, mono: true },
{ label: '耗时(ms)', value: props.record?.latencyMs, mono: true },
]))
const requestFields = computed(() => visibleFields([
{ label: '账号', value: accountLabel.value, mono: true },
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
{ label: '请求 ID', value: props.record?.requestId, mono: true },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId, mono: true },
{ label: '客户端 IP', value: props.record?.clientIp, mono: true },
{ label: '协议', value: props.record?.protocol, mono: true },
{ label: 'User-Agent', value: props.record?.userAgent, mono: true, wrap: true, fullWidth: true },
]))
const routeFields = computed(() => visibleFields([
{ label: '端点', value: props.record?.route, mono: true },
{ label: 'Provider', value: props.record?.provider, mono: true },
{ label: '认证类型', value: props.record?.authenticationKind, mono: true },
{ label: '账号 ID', value: props.record?.accountId, mono: true },
{ label: '请求模型', value: props.record?.requestedModel, mono: true },
{ label: '上游模型', value: props.record?.upstreamModel, mono: true },
{ label: '记录模型', value: props.record?.model, mono: true },
{ label: '推理强度', value: props.record?.reasoningEffort, mono: true },
{ label: '推理预设', value: props.record?.reasoningPreset, mono: true },
{ label: '客户端传输', value: props.record?.clientTransport, mono: true },
{ label: '上游传输', value: props.record?.transport, mono: true },
{ label: '服务档位', value: props.record?.serviceTier, mono: true },
{ label: '请求类型', value: props.record?.requestKind, mono: true },
{ label: '子代理类型', value: props.record?.subagentKind, mono: true },
{ label: '压缩请求', value: props.record?.compact, mono: true },
]))
const eventFields = computed(() => visibleFields([
{ label: '记录来源', value: props.record?.metadata.source, mono: true },
{ label: '组件', value: props.record?.metadata.component || props.record?.kind, mono: true },
{ label: '事件类型', value: props.record?.kind, mono: true },
{ label: '操作', value: props.record?.operation, mono: true },
{ label: '尝试序号', value: props.record?.attemptIndex },
{ label: '响应 ID', value: props.record?.responseId, mono: true },
{ label: '上游请求 ID', value: props.record?.upstreamRequestId, mono: true },
]))
const continuationFields = computed(() => visibleFields([
{
label: '会话关联 Hash',
value: props.record?.metadata.continuationAffinityHash,
mono: true,
wrap: true,
fullWidth: true,
},
{
label: 'Previous Response Hash',
value: props.record?.metadata.continuationPreviousResponseIdHash,
mono: true,
wrap: true,
fullWidth: true,
},
{ label: '续接不可用原因', value: props.record?.metadata.continuationUnavailableReason, mono: true },
{ label: '上游连接 ID', value: props.record?.metadata.upstreamConnectionId, mono: true },
{ label: '连接退出原因', value: props.record?.metadata.upstreamConnectionExitReason, mono: true },
{ label: '连接存活(ms)', value: props.record?.metadata.upstreamConnectionAgeMs, mono: true },
{ label: '最后空闲(ms)', value: props.record?.metadata.upstreamConnectionIdleMs, mono: true },
{ label: '恢复请求 ID', value: props.record?.metadata.recoveryRequestId, mono: true },
{ label: '恢复时间', value: props.record?.metadata.recoveredAt, mono: true },
{
label: '恢复尝试次数',
value: props.record?.metadata.recoveryAttemptCount
? props.record.metadata.recoveryAttemptCount
: null,
mono: true,
},
{ label: '客户端重连(ms)', value: props.record?.metadata.recoveryRetryDelayMs, mono: true },
{ label: '恢复总延迟(ms)', value: props.record?.metadata.recoveryTotalLatencyMs, mono: true },
]))
function visibleFields(items: DetailField[]) {
return items.filter(({ value }) => value !== null && value !== undefined && value !== '')
}
</script>
<template>
<ZDialog
v-model="open"
title="错误明细"
description="上游错误与请求上下文"
type="danger"
width="64rem"
>
<template v-if="record">
<section :class="panelClass">
<h3 :class="panelTitleClass">
错误
</h3>
<UsageDetailFieldGrid :items="errorFields" />
<div v-if="record.message" class="mt-3">
<UsageDetailCodePanel title="错误信息" max-height="220px" :content="record.message" />
</div>
</section>
<section v-if="record.errorDetails" class="mt-3" :class="panelClass">
<UsageDetailCodePanel
title="原始错误详情"
max-height="360px"
:content="record.errorDetails"
/>
</section>
<div class="mt-3 grid min-w-0 gap-3 lg:grid-cols-2">
<section v-if="requestFields.length" :class="panelClass">
<h3 :class="panelTitleClass">
请求与客户端
</h3>
<UsageDetailFieldGrid :items="requestFields" />
</section>
<section v-if="routeFields.length" :class="panelClass">
<h3 :class="panelTitleClass">
路由与模型
</h3>
<UsageDetailFieldGrid :items="routeFields" />
</section>
</div>
<section v-if="eventFields.length" class="mt-3" :class="panelClass">
<h3 :class="panelTitleClass">
事件记录
</h3>
<UsageDetailFieldGrid :items="eventFields" />
</section>
<section v-if="continuationFields.length" class="mt-3" :class="panelClass">
<h3 :class="panelTitleClass">
会话续接与物理连接
</h3>
<UsageDetailFieldGrid :items="continuationFields" />
</section>
</template>
<template #footer>
<DiagnosticsDownloadButton v-if="record" :request-id="record.requestId" :error-id="record.id" :active="open" />
<ZButton type="primary" @click="open = false">
关闭
</ZButton>
</template>
</ZDialog>
</template>
@@ -1,237 +0,0 @@
<script setup lang="ts">
import type { UsageTimeRangeParams } from '../composables/useUsageTimeRange'
import type { OpsError } from '@/api'
import { useTableColumns, ZIconButton, ZInput, ZPagination, ZTable, ZTableColumnSettings } from '@codex-proxy/ui'
import { Eye, RefreshCw, Search } from '@lucide/vue'
import { shallowRef, toRef } from 'vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { usageUserAgent } from '@/components/usage/shared/presenter'
import UsageClientIpCell from '@/components/usage/UsageClientIpCell.vue'
import { useOpsErrorsTable } from '../composables/useOpsErrorsTable'
import { opsErrorColumns } from '../constants'
import { opsErrorSummary } from '../utils/opsErrorPresentation'
import OpsErrorDetailModal from './OpsErrorDetailModal.vue'
const props = defineProps<{
timeRangeParams: UsageTimeRangeParams
latestTimeRangeParams: () => UsageTimeRangeParams
provider: string
active: boolean
}>()
const {
loading,
error,
refreshing,
records,
searchQuery,
pagination,
handlePageChange,
handlePageSizeChange,
refresh,
} = useOpsErrorsTable({
timeRangeParams: toRef(props, 'timeRangeParams'),
latestTimeRangeParams: () => props.latestTimeRangeParams(),
provider: toRef(props, 'provider'),
active: toRef(props, 'active'),
})
const selectedRecord = shallowRef<OpsError | null>(null)
const detailOpen = shallowRef(false)
const { visibleColumns, columnOptions, setColumnVisible, setColumnOrder, resetColumns } = useTableColumns(opsErrorColumns, 'ops-errors')
const upstreamSendStateLabels: Record<string, string> = {
sent: '已发送',
not_sent: '未发送',
ambiguous: '状态不明',
}
function showDetail(record: OpsError) {
selectedRecord.value = record
detailOpen.value = true
}
function handleRefresh() {
void refresh()
}
function accountText(record: OpsError) {
return record.accountEmail
|| record.accountName
|| record.metadata.accountLabel
|| record.accountId
|| '未记录'
}
function modelText(record: OpsError) {
return record.requestedModel || record.model || record.upstreamModel || '未记录'
}
function modelTitle(record: OpsError) {
const requested = record.requestedModel
const upstream = record.upstreamModel
return requested && upstream && requested !== upstream
? `${requested} → ${upstream}`
: modelText(record)
}
function upstreamSendStateText(value: string | null | undefined) {
if (!value)
return '未记录'
return upstreamSendStateLabels[value] ?? value
}
</script>
<template>
<div class="grid min-h-130 min-w-0 w-full 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"
aria-label="错误筛选与操作"
>
<div class="min-w-0 flex-1">
<ZInput
v-model="searchQuery"
placeholder="请求 ID、密钥名称或账号"
aria-label="搜索错误:请求 ID、密钥名称或账号"
class="min-w-0 w-full lg:max-w-96"
>
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</ZInput>
</div>
<div class="flex shrink-0 self-end items-center justify-end gap-2 lg:ml-auto">
<ZTableColumnSettings
:options="columnOptions"
@change="setColumnVisible"
@reorder="setColumnOrder"
@reset="resetColumns"
/>
<ZIconButton
size="default"
aria-label="刷新错误明细"
:loading="refreshing"
:disabled="loading || refreshing"
@click="handleRefresh"
>
<template #loading>
<RefreshCw class="size-4.5 animate-spin motion-reduce:animate-none" />
</template>
<RefreshCw class="size-4.5" />
</ZIconButton>
</div>
</div>
<div class="flex min-h-0 min-w-0 flex-col">
<p v-if="error && !loading" role="alert" class="text-cp-sm text-cp-error-text">
{{ error }},请刷新重试
</p>
<ZTable
v-else
class="min-h-0 flex-1"
:columns="visibleColumns"
:data="records"
:loading="loading"
empty-text="当前时段没有错误"
>
<template #clientApiKeyName="{ displayValue }">
<span
class="block max-w-full truncate font-mono text-cp-sm font-bold text-cp-text"
:title="String(displayValue)"
>
{{ displayValue }}
</span>
</template>
<template #provider="{ row }">
<ProviderIconGroup
:provider="String(row.provider || '')"
:authentication-kind="row.authenticationKind"
/>
</template>
<template #accountPlanType="{ row }">
<AccountPlanBadge
v-if="row.accountPlanType"
:plan-type="row.accountPlanType"
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
size="sm"
/>
<span v-else class="text-cp-text-quaternary">—</span>
</template>
<template #message="{ row }">
<div class="min-w-0 py-0.5" :title="row.message || opsErrorSummary(row)">
<div class="flex min-w-0 items-center gap-2">
<code class="block min-w-0 flex-1 truncate font-mono text-cp-sm font-bold text-cp-error-text">
{{ opsErrorSummary(row) }}
</code>
<span
v-if="row.metadata.recoveredAt"
class="inline-flex h-5 shrink-0 items-center rounded-full bg-cp-success-container px-2 text-cp-xs leading-none font-heavy text-cp-success-on-container"
>
已自动恢复
</span>
</div>
<p
v-if="row.message"
class="mt-1 mb-0 line-clamp-1 text-cp-xs leading-[1.45] font-emphasis text-cp-text-secondary"
>
{{ row.message }}
</p>
</div>
</template>
<template #upstreamSendState="{ row }">
<span
class="inline-flex h-6 max-w-full items-center rounded-full bg-cp-fill-quaternary px-2.5 font-mono text-cp-sm leading-none font-bold text-cp-text-secondary"
:title="row.upstreamSendState || '未记录'"
>
<span class="min-w-0 truncate">{{ upstreamSendStateText(row.upstreamSendState) }}</span>
</span>
</template>
<template #accountId="{ row }">
<span
class="block max-w-full truncate font-mono text-cp-sm font-bold text-cp-text"
:title="accountText(row)"
>
{{ accountText(row) }}
</span>
</template>
<template #model="{ row }">
<span
class="block max-w-full truncate font-mono text-cp-sm font-bold text-cp-text"
:title="modelTitle(row)"
>
{{ modelText(row) }}
</span>
</template>
<template #clientIp="{ row }">
<UsageClientIpCell :record="row" />
</template>
<template #userAgent="{ row }">
<span class="block max-w-full wrap-break-word whitespace-normal font-mono text-cp-sm leading-[1.4] font-emphasis text-cp-text-secondary">
{{ usageUserAgent(row) }}
</span>
</template>
<template #actions="{ row }">
<ZIconButton
size="default"
aria-label="查看错误详情"
@click="showDetail(row)"
>
<Eye class="size-4.5" />
</ZIconButton>
</template>
</ZTable>
<ZPagination
v-bind="pagination"
:disabled="loading"
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
</div>
<OpsErrorDetailModal v-model="detailOpen" :record="selectedRecord" />
</template>
@@ -16,8 +16,8 @@ const search = defineModel<string>('search', { required: true })
</script>
<template>
<div class="flex w-full items-center gap-3" role="group" aria-label="使用记录筛选与操作">
<div class="min-w-0 flex-1 sm:w-96 sm:flex-none">
<div class="flex w-full flex-wrap items-center gap-3" role="group" aria-label="使用记录筛选与操作">
<div class="w-full min-w-0 sm:w-96 sm:flex-none">
<ZInput v-model="search" placeholder="密钥名称、账号或模型" aria-label="搜索使用记录:密钥名称、账号或模型" class="w-full">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
@@ -1,104 +0,0 @@
import type { Ref } from 'vue'
import type { UsageTimeRangeParams } from './useUsageTimeRange'
import { computed, shallowRef, watch } from 'vue'
import { getOpsErrors } from '@/api'
import { useStablePagedQuery } from '@/composables/useStablePagedQuery'
import { withMinimumDuration } from '@/utils/operation'
interface UseOpsErrorsTableOptions {
timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>
latestTimeRangeParams: () => UsageTimeRangeParams
provider: Readonly<Ref<string>>
active: Readonly<Ref<boolean>>
}
export function useOpsErrorsTable(options: UseOpsErrorsTableOptions) {
const refreshing = shallowRef(false)
const searchQuery = shallowRef('')
const search = computed(() => searchQuery.value.trim() || undefined)
// 时间、平台和搜索共同构成分页快照,避免翻页混入另一组筛选结果。
let tableParams = snapshot()
function snapshot() {
return {
...options.latestTimeRangeParams(),
provider: options.provider.value || undefined,
search: search.value,
}
}
const query = useStablePagedQuery({
initialPageSize: 10,
load: ({ currentPage, pageSize }, requestOptions) => getOpsErrors({
currentPage,
pageSize,
...tableParams,
}, requestOptions),
})
const pagination = computed(() => ({
currentPage: query.currentPage.value,
pageSize: query.pageSize.value,
total: query.total.value,
}))
function handlePageChange(nextPage: number) {
if (tableParams.search !== search.value) {
void reloadLatest()
return
}
void query.execute(nextPage)
}
function handlePageSizeChange(nextPageSize: number) {
query.pageSize.value = nextPageSize
if (tableParams.search !== search.value)
void reloadLatest()
else
void query.reloadFromStart()
}
function reloadLatest() {
tableParams = snapshot()
return query.reloadFromStart()
}
async function refresh() {
if (refreshing.value || query.loading.value)
return
refreshing.value = true
try {
await withMinimumDuration(reloadLatest)
}
finally {
refreshing.value = false
}
}
watch(search, (_value, _previous, onCleanup) => {
const timer = setTimeout(() => {
if (options.active.value && tableParams.search !== search.value)
void reloadLatest()
}, 250)
onCleanup(() => clearTimeout(timer))
})
watch([options.timeRangeParams, options.provider, options.active], () => {
if (options.active.value)
void reloadLatest()
else
query.invalidate()
}, { immediate: true })
return {
loading: query.loading,
error: query.error,
refreshing,
records: query.items,
searchQuery,
pagination,
handlePageChange,
handlePageSizeChange,
refresh,
}
}
@@ -1,5 +1,6 @@
import type { Ref } from 'vue'
import type { UsageTimeRangeParams } from './useUsageTimeRange'
import type { UsageRecordStatus } from '@/api'
import type { AnalyticsMetadata } from '@/api/modules/analytics'
import { computed, onMounted, onScopeDispose, shallowRef, watch } from 'vue'
@@ -15,7 +16,7 @@ import { withMinimumDuration } from '@/utils/operation'
interface UseUsageRecordsTableOptions {
timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>
latestTimeRangeParams: () => UsageTimeRangeParams
active: Readonly<Ref<boolean>>
status: Readonly<Ref<UsageRecordStatus>>
}
type UsageLoadScope = 'all' | 'table'
@@ -59,6 +60,7 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
...options.latestTimeRangeParams(),
provider: providerQuery.value || undefined,
search: search.value,
status: options.status.value,
}
}
@@ -77,7 +79,7 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
resetTable()
await Promise.all([
...(options.active.value ? [table.execute()] : []),
table.execute(),
...(scope === 'all' ? [loadUsageAnalytics(globalParams)] : []),
])
}
@@ -194,18 +196,13 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
void loadUsageRecords()
})
watch(options.active, (active) => {
if (active) {
void reloadLatestTable()
}
else {
table.invalidate()
}
watch(options.status, () => {
void reloadLatestTable()
})
watch(search, (_value, _previous, onCleanup) => {
const timer = setTimeout(() => {
if (options.active.value && tableParams.search !== search.value)
if (tableParams.search !== search.value)
void reloadLatestTable()
}, 250)
onCleanup(() => clearTimeout(timer))
-19
View File
@@ -1,22 +1,3 @@
import type { OpsError } from '@/api'
import { defineTableColumns } from '@codex-proxy/ui'
export const opsErrorColumns = defineTableColumns<OpsError>([
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' },
{ key: 'accountId', label: '账号', kind: 'identity', size: '3xl', emptyText: '未知账号', hideable: false },
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'sm' },
{ key: 'message', label: '错误', kind: 'custom', size: '4xl', hideable: false },
{ key: 'upstreamSendState', label: '发送状态', kind: 'custom', size: 'xl' },
{ key: 'model', label: '模型', kind: 'custom', size: 'xl', emptyText: '未记录模型' },
{ key: 'route', label: '端点', kind: 'mono', size: 'xl', emptyText: '未记录' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'requestId', label: '请求 ID', kind: 'mono', size: '2xl', emptyText: '未记录' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl', emptyText: '未记录' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl', emptyText: '未记录' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm', hideable: false },
])
export const usageTimeRangeOptions = [
{ label: '今天', value: 'today' },
{ label: '最近 7 天', value: '7d' },
+19 -30
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { UsageRecordStatus } from '@/api'
import { useTableColumns, ZCard, ZIconButton, ZPageHeader, ZPagination, ZSegmented, ZSelect, ZTableColumnSettings } from '@codex-proxy/ui'
import { Eye } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { CloudRain, Eye, List, Sun } from '@lucide/vue'
import { shallowRef, watch } from 'vue'
import ProviderFilter from '@/components/ProviderFilter.vue'
import { usageRecordColumns } from '@/components/usage/shared/columns'
import UsageRecordsTable from '@/components/usage/UsageRecordsTable.vue'
import OpsErrorPanel from './components/OpsErrorPanel.vue'
import UsageFilters from './components/UsageFilters.vue'
import UsageInsightsGrid from './components/UsageInsightsGrid.vue'
import UsageRecordDetailModal from './components/UsageRecordDetailModal.vue'
@@ -16,11 +16,12 @@ import { useUsageRecordsTable } from './composables/useUsageRecordsTable'
import { useUsageTimeRange } from './composables/useUsageTimeRange'
import { usageTimeRangeOptions } from './constants'
const recordView = shallowRef('success')
const recordView = shallowRef<UsageRecordStatus>('success')
const { visibleColumns, columnOptions, setColumnVisible, setColumnOrder, resetColumns } = useTableColumns(usageRecordColumns, 'usage-records')
const recordViewOptions = [
{ label: '成功记录', value: 'success' },
{ label: '错误排查', value: 'errors' },
{ label: '成功', value: 'success', icon: Sun },
{ label: '错误', value: 'error', icon: CloudRain },
{ label: '全部', value: 'all', icon: List },
]
const { timeRange, timeRangeParams, refreshTimeRangeEnd, latestTimeRangeParams }
= useUsageTimeRange()
@@ -45,7 +46,7 @@ const {
} = useUsageRecordsTable({
timeRangeParams,
latestTimeRangeParams,
active: computed(() => recordView.value === 'success'),
status: recordView,
})
const { showDetailModal, selectedUsageRecord, handleViewDetail } = useUsageRecordDetail()
@@ -82,24 +83,20 @@ watch(timeRange, () => {
class="mt-5 flex flex-col"
>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-text">
请求明细
</h2>
<p
class="mt-1.75 mb-0 text-cp leading-[1.15] font-emphasis text-cp-text-secondary"
>
成功请求与失败请求明细
</p>
</div>
<ZSegmented v-model="recordView" aria-label="请求明细类型" :options="recordViewOptions" class="w-52" />
<div>
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-cp-text">
请求明细
</h2>
<p
class="mt-1.75 mb-0 text-cp leading-[1.15] font-emphasis text-cp-text-secondary"
>
成功请求与失败请求明细
</p>
</div>
</template>
<template #body>
<div
v-show="recordView === 'success'"
class="grid min-h-130 min-w-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3"
>
<UsageFilters
@@ -109,6 +106,7 @@ watch(timeRange, () => {
@refresh="refreshUsageRecords"
>
<template #actions>
<ZSegmented v-model="recordView" aria-label="请求明细类型" :options="recordViewOptions" display="icon" class="w-30 shrink-0" />
<ZTableColumnSettings
:options="columnOptions"
@change="setColumnVisible"
@@ -124,7 +122,7 @@ watch(timeRange, () => {
:columns="visibleColumns"
:rows="records"
:loading="loading"
:empty-text="error ? `加载失败:${error}` : '暂无使用记录'"
:empty-text="error ? `加载失败:${error}` : '所选条件下暂无请求记录'"
>
<template #actions="{ row }">
<div class="flex items-center justify-start">
@@ -146,15 +144,6 @@ watch(timeRange, () => {
/>
</div>
</div>
<div v-show="recordView === 'errors'" class="min-h-130 min-w-0 flex-1">
<OpsErrorPanel
:time-range-params="timeRangeParams"
:latest-time-range-params="latestTimeRangeParams"
:provider="providerQuery"
:active="recordView === 'errors'"
/>
</div>
</template>
</ZCard>
@@ -1,16 +0,0 @@
import type { OpsError } from '@/api'
const failureClassLabels: Readonly<Record<string, string>> = {
continuation_recovery_required: '会话续接需要重建',
}
export function failureClassText(value: string | null | undefined) {
if (!value)
return '未记录'
return failureClassLabels[value] ?? value
}
export function opsErrorSummary(record: OpsError) {
const failureLabel = failureClassLabels[record.failureClass]
return failureLabel ?? record.providerErrorCode ?? record.failureClass
}