mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 项,
|
||||
同请求量按维度标识稳定排列
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user