fix(usage): align request tables and detail dialogs

Share data columns and status filters between admin and key usage. Keep final error summaries in the error view and focus error details in a separate dialog.

Use logical request outcomes for key usage records and keep concurrency lists at a stable height without pagination.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-18 04:28:41 +08:00
co-authored by Codex
parent 72bdc234ca
commit c3146dc10a
35 changed files with 516 additions and 388 deletions
@@ -3,8 +3,8 @@
use super::{
client_keys::ClientKeyRecord,
observability::{
HealthTimeline, ObservabilityPageSize, OpsErrorPage, RequestMetricPoint, TimeRange,
UsageOverview, UsagePage,
HealthTimeline, ObservabilityPageSize, RequestMetricPoint, TimeRange, UsageOverview,
UsagePage,
},
};
@@ -18,6 +18,7 @@ pub struct KeyUsageQuery {
pub enum KeyUsageRecordKind {
Success,
Error,
All,
}
#[derive(Debug, Clone)]
@@ -35,7 +36,4 @@ pub struct KeyUsageOverview {
pub health_timeline: HealthTimeline,
}
pub enum KeyUsageRecords {
Success(UsagePage),
Error(OpsErrorPage),
}
pub type KeyUsageRecords = UsagePage;
@@ -664,6 +664,9 @@ pub struct DashboardObservation {
#[derive(Debug, Clone, PartialEq)]
pub struct UsageListRecord {
pub outcome: RequestOutcome,
pub error_code: Option<String>,
pub error_message: Option<String>,
pub client_status_code: Option<u16>,
pub client_api_key_name: Option<String>,
pub id: String,
pub endpoint: String,
@@ -14,9 +14,7 @@ use crate::{
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
KeyUsageRecordsQuery,
},
observability::{
Granularity, OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery,
},
observability::{Granularity, TimeRange, UsageFilter, UsageQuery, UsageRecordStatus},
system::SystemVersion,
},
ports::store::{ClientKeyStore, ObservabilityStore},
@@ -221,35 +219,21 @@ impl KeyUsageService for DefaultKeyUsageService {
let Some(id) = self.key_id(session_id).await? else {
return Ok(None);
};
let result = match query.kind {
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,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage records"))?,
),
KeyUsageRecordKind::Error => KeyUsageRecords::Error(
self.observations
.list_ops_errors(OpsErrorQuery {
range: query.usage.range,
filter: OpsErrorFilter {
client_api_key_ref: Some(id.as_str().to_owned()),
model: query.usage.model,
..OpsErrorFilter::default()
},
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage errors"))?,
),
};
let result = self
.observations
.list_usage_records(UsageQuery {
status: match query.kind {
KeyUsageRecordKind::Success => UsageRecordStatus::Success,
KeyUsageRecordKind::Error => UsageRecordStatus::Error,
KeyUsageRecordKind::All => UsageRecordStatus::All,
},
range: query.usage.range,
filter: usage_filter(&id, query.usage.model),
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage records"))?;
Ok(Some(result))
}
}
@@ -1219,6 +1219,9 @@ fn total_record(
now: DateTime<Utc>,
) -> UsageListRecord {
UsageListRecord {
error_code: None,
error_message: None,
client_status_code: None,
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: id.to_owned(),
@@ -262,6 +262,8 @@ pub(crate) fn usage_list_record_view(
.clone()
.or_else(|| record.requested_model_id.clone());
UsageListRecordView {
error_code: record.error_code,
error_message: record.error_message,
logical_outcome: record.outcome.as_str().to_owned(),
client_api_key_name: record.client_api_key_name,
id: record.id,
@@ -86,6 +86,8 @@ pub struct ImageBillingView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageListRecordView {
pub error_code: Option<String>,
pub error_message: Option<String>,
pub logical_outcome: String,
pub client_api_key_name: Option<String>,
pub id: String,
@@ -5,7 +5,7 @@ use gateway_admin::model::{
client_keys::ClientKeySecret,
key_usage::{KeyUsageOverview, KeyUsageRecords},
observability::{
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
CostCoverage, CurrencyCost, Granularity, RequestMetrics, RequestOutcome, UsageListRecord,
},
system::SystemVersion,
};
@@ -228,8 +228,20 @@ struct OutputTimingView {
first_text_ms: Option<u64>,
}
fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
let token_details = Some(usage_list_token_details(&value));
fn usage_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
let token_details = [
value.input_tokens,
value.output_tokens,
value.cached_input_tokens,
value.cache_write_input_tokens,
value.reasoning_output_tokens,
value.image_input_tokens,
value.image_output_tokens,
value.total_tokens,
]
.iter()
.any(Option::is_some)
.then(|| usage_list_token_details(&value));
let billing = billing_view(value.billing.as_ref());
RecordView {
id: value.id,
@@ -258,58 +270,30 @@ fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> R
},
client_ip: value.client_ip,
user_agent: value.user_agent,
status: "success",
// 成功记录不保存 HTTP 状态,不能用 200 伪造缺失的原始事实
status_code: None,
}
}
fn error_record(value: OpsError, time: crate::time::TimePresenter) -> RecordView {
RecordView {
id: value.event_id,
created_at: value.occurred_at,
created_at_display: time.datetime(&value.occurred_at),
model: value.requested_model_id,
route: value.endpoint,
reasoning_effort: value.reasoning_effort,
client_transport: value.client_transport,
upstream_transport: value.upstream_transport,
token_details: None,
billing: None,
latency_ms: value.latency_ms,
first_token_latency_ms: None,
latency_details: OutputTimingView::default(),
client_ip: value.client_ip,
user_agent: value.user_agent,
status: "error",
status: match value.outcome {
RequestOutcome::Succeeded => "success",
RequestOutcome::Failed => "error",
RequestOutcome::Incomplete => "incomplete",
RequestOutcome::Cancelled => "cancelled",
RequestOutcome::Running => "running",
RequestOutcome::Other(_) => "unknown",
},
status_code: value.client_status_code,
}
}
pub(super) fn records(
value: KeyUsageRecords,
page: KeyUsageRecords,
time: crate::time::TimePresenter,
) -> PageData<RecordView> {
match value {
KeyUsageRecords::Success(page) => PageData {
items: page
.items
.into_iter()
.map(|value| success_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
KeyUsageRecords::Error(page) => PageData {
items: page
.items
.into_iter()
.map(|value| error_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
PageData {
items: page
.items
.into_iter()
.map(|value| usage_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
}
}
@@ -67,6 +67,7 @@ enum RecordKind {
#[default]
Success,
Error,
All,
}
#[derive(Deserialize)]
@@ -109,6 +110,7 @@ impl RecordsQuery {
kind: match self.kind {
RecordKind::Success => KeyUsageRecordKind::Success,
RecordKind::Error => KeyUsageRecordKind::Error,
RecordKind::All => KeyUsageRecordKind::All,
},
current_page,
page_size,
@@ -801,6 +801,9 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.lock()
.expect("usage records")
.push(UsageListRecord {
error_code: None,
error_message: None,
client_status_code: None,
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: "request_endpoint".to_owned(),
@@ -892,12 +895,18 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
});
records.push(image);
}
let mut failed = records[0].clone();
failed.id = "request_failed".to_owned();
failed.outcome = gateway_admin::model::observability::RequestOutcome::Failed;
failed.error_code = Some("server_error".to_owned());
failed.error_message = Some("The upstream service is temporarily unavailable.".to_owned());
records.push(failed);
}
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/records")
.uri("/api/admin/usage/records?status=all")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_usage_endpoint")
.body(Body::empty())
@@ -911,6 +920,13 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.expect("usage response body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON");
assert!(value["data"]["items"][0]["errorCode"].is_null());
assert!(value["data"]["items"][0]["errorMessage"].is_null());
assert_eq!(value["data"]["items"][3]["errorCode"], "server_error");
assert_eq!(
value["data"]["items"][3]["errorMessage"],
"The upstream service is temporarily unavailable."
);
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
assert_eq!(
@@ -5,9 +5,8 @@ use chrono::{Duration, Utc};
use gateway_admin::model::{
client_keys::ClientKeyRecord,
observability::{
AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, OpsError,
RequestMetricPoint, RequestMetrics, TimeRange, UsageBilling, UsageListRecord,
UsageOverview,
AttemptMetrics, CostCoverage, CurrencyCost, DecimalAmount, Granularity, RequestMetricPoint,
RequestMetrics, TimeRange, UsageBilling, UsageListRecord, UsageOverview,
},
};
use gateway_core::{
@@ -87,12 +86,14 @@ pub(super) async fn fixture() -> AdminTestFixture {
}]);
}
fixture.usage_records.lock().unwrap().push(usage_record());
fixture.ops_errors.lock().unwrap().push(error_record());
fixture
}
fn usage_record() -> UsageListRecord {
UsageListRecord {
error_code: None,
error_message: None,
client_status_code: None,
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: "req-visible".to_owned(),
@@ -158,61 +159,3 @@ fn usage_record() -> UsageListRecord {
started_at: Utc::now(),
}
}
fn error_record() -> OpsError {
OpsError {
client_api_key_name: Some("Production".to_owned()),
source: "private-sentinel".to_owned(),
event_id: "error-visible".to_owned(),
request_id: Some("private-sentinel".to_owned()),
attempt_index: None,
client_api_key_ref: Some("private-sentinel".to_owned()),
component: "private-sentinel".to_owned(),
operation: "private-sentinel".to_owned(),
protocol: Some("private-sentinel".to_owned()),
client_transport: Some("http_sse".to_owned()),
requested_model_id: Some("coding".to_owned()),
service_tier: Some("private-sentinel".to_owned()),
endpoint: Some("/v1/responses".to_owned()),
provider_kind: Some("private-sentinel".to_owned()),
provider_account_ref: Some("private-sentinel".to_owned()),
provider_account_name: Some("private-sentinel".to_owned()),
provider_account_email: Some("private-sentinel".to_owned()),
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
provider_account_plan_type: Some("private-sentinel".to_owned()),
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
upstream_model_id: Some("private-sentinel".to_owned()),
upstream_transport: Some("websocket".to_owned()),
failure_kind: "private-sentinel".to_owned(),
upstream_send_state: Some("private-sentinel".to_owned()),
client_status_code: Some(502),
upstream_status_code: Some(503),
provider_error_code: Some("private-sentinel".to_owned()),
client_response_id: Some("private-sentinel".to_owned()),
upstream_request_id: Some("private-sentinel".to_owned()),
latency_ms: Some(420),
message: "private-sentinel".to_owned(),
error_details: Some("private-sentinel".to_owned()),
client_ip: Some("192.0.2.42".to_owned()),
user_agent: Some("key-usage-test/1.0".to_owned()),
reasoning_effort: Some("xhigh".to_owned()),
reasoning_preset: Some("private-sentinel".to_owned()),
request_kind: Some("private-sentinel".to_owned()),
subagent_kind: Some("private-sentinel".to_owned()),
compact: None,
continuation_affinity_hash: Some("private-sentinel".to_owned()),
continuation_previous_response_id_hash: Some("private-sentinel".to_owned()),
continuation_unavailable_reason: Some("private-sentinel".to_owned()),
upstream_connection_id: Some("private-sentinel".to_owned()),
upstream_connection_exit_reason: Some("private-sentinel".to_owned()),
upstream_connection_age_ms: None,
upstream_connection_idle_ms: None,
recovery_request_id: Some("private-sentinel".to_owned()),
recovered_at: None,
recovery_attempt_count: 0,
recovery_retry_delay_ms: None,
recovery_total_latency_ms: None,
occurred_at: Utc::now(),
stable_sort_id: "private-sentinel".to_owned(),
}
}
@@ -8,7 +8,7 @@ use axum::{
response::Response,
};
use chrono::Utc;
use gateway_admin::model::observability::RequestMetrics;
use gateway_admin::model::observability::{RequestMetrics, RequestOutcome, UsageRecordStatus};
use serde_json::{Value, json};
use tower::ServiceExt as _;
@@ -255,7 +255,16 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
for kind in ["success", "error"] {
for (kind, outcome) in [
("success", RequestOutcome::Succeeded),
("error", RequestOutcome::Failed),
("all", RequestOutcome::Incomplete),
] {
{
let mut records = fixture.usage_records.lock().unwrap();
records[0].outcome = outcome;
records[0].client_status_code = (kind != "success").then_some(502);
}
let response = get(
&app,
"records",
@@ -291,7 +300,10 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
"statusCode",
],
);
assert_eq!(record["status"], kind);
assert_eq!(
record["status"],
if kind == "all" { "incomplete" } else { kind }
);
assert_eq!(record["model"], "coding");
assert_eq!(record["reasoningEffort"], "xhigh");
assert_eq!(record["clientTransport"], "http_sse");
@@ -323,22 +335,54 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
assert!(record["statusCode"].is_null());
} else {
assert_eq!(record["statusCode"], 502);
assert!(record["billing"].is_null());
assert!(record["tokenDetails"].is_null());
assert_eq!(record["latencyDetails"], json!({}));
assert_eq!(record["billing"]["totalAmountDisplay"], "$0.1235");
assert_eq!(record["tokenDetails"]["totalTokens"], 1100);
}
}
let data = fixture.observations.lock().unwrap();
assert_eq!(
data.records[0].filter.client_api_key_ref.as_deref(),
Some("key-42")
);
assert_eq!(
data.errors[0].filter.client_api_key_ref.as_deref(),
Some("key-42")
);
assert_eq!(data.records[0].filter.model.as_deref(), Some("coding"));
assert_eq!(data.errors[0].filter.model.as_deref(), Some("coding"));
assert!(data.errors.is_empty());
assert_eq!(data.records.len(), 3);
for (query, status) in data.records.iter().zip([
UsageRecordStatus::Success,
UsageRecordStatus::Error,
UsageRecordStatus::All,
]) {
assert_eq!(query.status, status);
assert_eq!(query.filter.client_api_key_ref.as_deref(), Some("key-42"));
assert_eq!(query.filter.model.as_deref(), Some("coding"));
assert!(query.filter.provider_account_ref.is_none());
assert!(query.filter.provider_kind.is_none());
}
}
#[tokio::test]
async fn failed_records_without_usage_keep_token_and_billing_details_unknown() {
let fixture = fixtures::fixture().await;
{
let mut records = fixture.usage_records.lock().unwrap();
let record = &mut records[0];
record.outcome = RequestOutcome::Failed;
record.client_status_code = Some(502);
record.input_tokens = None;
record.output_tokens = None;
record.cached_input_tokens = None;
record.cache_write_input_tokens = None;
record.reasoning_output_tokens = None;
record.image_input_tokens = None;
record.image_output_tokens = None;
record.total_tokens = None;
record.billing = None;
}
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
let response = get(&app, "records", "&kind=error", &cookie).await;
assert_eq!(response.status(), StatusCode::OK);
let data = response_json(response).await;
let record = &data["data"]["items"][0];
assert_eq!(record["status"], "error");
assert_eq!(record["statusCode"], 502);
assert!(record["tokenDetails"].is_null());
assert!(record["billing"].is_null());
}
#[tokio::test]
@@ -392,7 +436,7 @@ async fn unknown_scope_fields_and_unbounded_queries_are_rejected() {
"&currentPage=0",
"&pageSize=0",
"&pageSize=101",
"&kind=all",
"&kind=unknown",
"&currentPage=-1",
] {
assert!(
@@ -303,6 +303,9 @@ pub(crate) fn usage_list_record_from_row(
cost_currency.as_deref(),
)?;
Ok(UsageListRecord {
error_code: get(row, "error_code")?,
error_message: get(row, "error_message")?,
client_status_code: optional_status(row, "client_status_code")?,
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")?,
@@ -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.outcome, 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.provider_error_code as error_code, mr.error_message, mr.client_status_code, 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,
@@ -311,7 +311,7 @@ async fn request_status_pages_share_one_request_timeline() {
.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'",
r#"update model_requests set requested_model_id='model_status_b',request_observation_json=jsonb_set(request_observation_json,'{error}','{"providerErrorCode":"server_error","message":"The upstream service is temporarily unavailable."}'::jsonb),client_status_code=502 where id='status_b'"#,
)
.execute(&database.pool)
.await
@@ -377,6 +377,12 @@ async fn request_status_pages_share_one_request_timeline() {
.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].id, "status_b");
assert_eq!(page.items[0].client_status_code, Some(502));
assert_eq!(page.items[0].error_code.as_deref(), Some("server_error"));
assert_eq!(
page.items[0].error_message.as_deref(),
Some("The upstream service is temporarily unavailable.")
);
// 诊断包仍可按请求 ID 关联中间事件,不受列表时间范围或最终结果限制
let errors = super::admin_observability_store(&database.pool)
.diagnostic_errors(&DiagnosticTarget::Request("status_a".into()), 100)
+7 -4
View File
@@ -656,13 +656,14 @@ call 与账号的绑定保存在网关进程内,未连接时一小时过期;
| 方法 | 路由 | 查询 | 说明 |
| --- | --- | --- | --- |
| `GET` | `/api/key-usage/overview` | `period?`、`asOf?` 或 `startTime?`、`endTime?`,另含 `model?` | 用量汇总、趋势、当前额度和锚点所在日期的健康时间线 |
| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的成功请求或错误记录 |
| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的请求记录 |
| `GET` | `/api/key-usage/config` | 无 | 当前 Key 的客户端配置凭据 |
| `GET` | `/api/key-usage/version` | 无 | “关于”弹窗使用的当前版本号和提交号 |
用量查询支持[页面时间合同](#页面时间合同),默认 `period=7d`,一次最多 31 天。模型按完整名称匹配;
不接受 Key ID、账号、Provider 等范围参数或其他未知字段。页码默认 1,每页默认 20,允许 1–100 条;
`kind` 为 `success`(默认)或 `error`。分页响应为 `{ items, currentPage, pageSize, total }`
`kind` 为 `success`(默认)、`error` 或 `all`,沿用使用统计的请求结果筛选:错误包括失败和未完成请求,
全部还包括取消与进行中的请求;一次请求只占一条记录。分页响应为 `{ items, currentPage, pageSize, total }`
overview 返回 `asOf`、`asOfDisplay`、`startTime`、`endTime`、`key`、`summary`、`trend`、`healthTimeline`。
`key` 仅包含名称、掩码前缀、并发/RPM、日与周限额、已用 USD 及重置时间;零限额表示不限,
@@ -677,8 +678,8 @@ overview 返回 `asOf`、`asOfDisplay`、`startTime`、`endTime`、`key`、`summ
日志只返回时间、公开请求模型、推理强度、接口、上下游传输方式、当前请求的 IP / User-Agent、
Token 明细、费用明细、用时/首字与状态。Token 和费用复用现有展示合同;延迟仅包含当前请求的首事件、
首推理、首文本、总耗时与上游性能指标,口径见[记录范围与统计口径](#记录范围与统计口径),不含账号容量或调度诊断。
成功记录的 `status` 为 `success`,不伪造未保存的 HTTP 状态;错误记录为 `error`,只返回客户端状态码,
缺失的 Token/费用明细为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容
`status` 为 `success`、`error`、`incomplete`、`cancelled`、`running` 或 `unknown`,`statusCode` 只返回已记录的客户端状态码。
各结果均保留已采集的 Token/费用明细,缺失时为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容
version 返回 `{ version, gitSha }`,不接受查询参数,不包含部署模式、更新状态或内部诊断
@@ -1974,6 +1975,8 @@ request/response/upstream ID、outcome 与搜索文本。`search` 仅供明细
重试中间错误不改变请求最终分类;`logicalOutcome` 保留真实结果。状态筛选只影响明细列表,
不改变顶部统计,独立运维事件仍通过 `/api/admin/operations/errors` 查询
请求列表的 `errorCode` / `errorMessage` 提供最终请求的错误码与错误信息,未记录时为 `null`
诊断 `dimension` 可取 `model`、`account`、
`apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项,
同请求量按维度标识稳定排列
+2 -2
View File
@@ -59,7 +59,7 @@ export interface KeyUsageOverview {
healthTimeline: DashboardHealthTimeline
}
export type KeyUsageRecordKind = 'success' | 'error'
export type KeyUsageRecordKind = 'success' | 'error' | 'all'
export interface KeyUsageRecord {
id: string
@@ -77,7 +77,7 @@ export interface KeyUsageRecord {
latencyDetails: Pick<UsageLatencyDetails, 'firstEventMs' | 'firstReasoningMs' | 'firstTextMs'>
clientIp: string | null
userAgent: string | null
status: KeyUsageRecordKind
status: 'success' | 'error' | 'incomplete' | 'cancelled' | 'running' | 'unknown'
statusCode: number | null
}
+2
View File
@@ -85,6 +85,8 @@ export type UsageRecordStatus = 'success' | 'error' | 'all'
export interface UsageListRecord {
logicalOutcome: string
errorCode: string | null
errorMessage: string | null
clientApiKeyName: string | null
id: string
provider: string | null
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { UsageListRecord } from '@/api'
import type { UsageModelRecord } from './shared/presenter'
import { CornerDownRight } from '@lucide/vue'
@@ -7,7 +7,7 @@ import { computed } from 'vue'
import { usageModelDisplay } from './shared/presenter'
const props = defineProps<{
record: UsageListRecord
record: UsageModelRecord
}>()
const modelDisplay = computed(() => usageModelDisplay(props.record))
@@ -1,11 +1,11 @@
<script setup lang="ts">
import type { UsageListRecord } from '@/api'
import type { UsageReasoningRecord } from './shared/presenter'
import { Bot, SquareDashedBottomCode } from '@lucide/vue'
import { usageReasoningEffort } from './shared/presenter'
defineProps<{
record: UsageListRecord
record: UsageReasoningRecord
}>()
</script>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { UsageRecordStatus } from '@/api/modules/usage'
import { ZSegmented } from '@codex-proxy/ui'
import { CloudRain, List, Sun } from '@lucide/vue'
const model = defineModel<UsageRecordStatus>({ required: true })
const options = [
{ label: '成功', value: 'success', icon: Sun },
{ label: '错误', value: 'error', icon: CloudRain },
{ label: '全部', value: 'all', icon: List },
]
</script>
<template>
<ZSegmented v-model="model" aria-label="请求明细类型" :options="options" display="icon" class="w-30 shrink-0" />
</template>
@@ -3,7 +3,6 @@ import type { TableColumn } from '@codex-proxy/ui'
import type { UsageListRecord } from '@/api'
import { ZTable } from '@codex-proxy/ui'
import { Minimize2 } from '@lucide/vue'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import UsageBillingCell from '@/components/usage/UsageBillingCell.vue'
@@ -12,6 +11,8 @@ import UsageLatencyCell from '@/components/usage/UsageLatencyCell.vue'
import UsageModelCell from '@/components/usage/UsageModelCell.vue'
import UsagePerformanceCell from '@/components/usage/UsagePerformanceCell.vue'
import UsageReasoningEffortCell from '@/components/usage/UsageReasoningEffortCell.vue'
import UsageRouteCell from '@/components/usage/UsageRouteCell.vue'
import UsageTimeCell from '@/components/usage/UsageTimeCell.vue'
import UsageTokenCell from '@/components/usage/UsageTokenCell.vue'
import UsageTransportBadge from '@/components/usage/UsageTransportBadge.vue'
import {
@@ -83,6 +84,10 @@ withDefaults(
<span v-else class="text-cp-text-quaternary">—</span>
</template>
<template #createdAtDisplay="{ row }">
<UsageTimeCell :value="row.createdAtDisplay" />
</template>
<template #clientIp="{ row }">
<UsageClientIpCell :record="row" />
</template>
@@ -102,16 +107,17 @@ withDefaults(
</template>
<template #route="{ row }">
<div class="inline-flex max-w-full items-center gap-1.5 whitespace-nowrap">
<code class="font-mono text-cp-sm font-emphasis">{{ row.route || '—' }}</code>
<span
v-if="row.compact"
class="inline-flex shrink-0 text-cp-orange-text"
title="压缩请求"
aria-label="压缩请求"
>
<Minimize2 class="size-3.5" stroke-width="2.4" />
</span>
<UsageRouteCell :record="row" />
</template>
<template #errorMessage="{ row }">
<div class="min-w-0 py-0.5" :title="row.errorMessage || row.errorCode || undefined">
<code class="block truncate font-mono text-cp-sm font-bold text-cp-error-text">
{{ row.errorCode || (row.logicalOutcome === 'incomplete' ? '响应未完成' : '请求失败') }}
</code>
<p v-if="row.errorMessage" class="mt-1 mb-0 truncate text-cp-xs leading-[1.45] font-emphasis text-cp-text-secondary">
{{ row.errorMessage }}
</p>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { Minimize2 } from '@lucide/vue'
defineProps<{ record: { route: string | null, compact?: boolean } }>()
</script>
<template>
<div class="inline-flex max-w-full items-center gap-1.5 whitespace-nowrap">
<code class="font-mono text-cp-sm font-emphasis">{{ record.route || '—' }}</code>
<span
v-if="record.compact"
class="inline-flex shrink-0 text-cp-orange-text"
title="压缩请求"
aria-label="压缩请求"
>
<Minimize2 class="size-3.5" stroke-width="2.4" />
</span>
</div>
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{ value: string }>()
</script>
<template>
<span class="block truncate font-mono text-cp-sm leading-none font-bold text-cp-text-secondary" :title="value">
{{ value || '—' }}
</span>
</template>
+17 -12
View File
@@ -1,7 +1,23 @@
import type { TableColumn } from '@codex-proxy/ui'
import type { UsageListRecord } from '@/api'
import { defineTableColumns } from '@codex-proxy/ui'
import { formatProviderLabel } from '@/utils/providers'
export const usageDataColumns = [
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'status', size: 'md' },
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
{ key: 'billing', label: '费用', kind: 'numeric', size: 'xl' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
{ key: 'performance', label: '速率', kind: 'numeric', size: 'lg' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
] satisfies TableColumn[]
export const usageRecordColumns = defineTableColumns<UsageListRecord>([
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' },
{
@@ -20,17 +36,6 @@ export const usageRecordColumns = defineTableColumns<UsageListRecord>([
fixedWidth: true,
formatter: (value: unknown) => formatProviderLabel(typeof value === 'string' ? value : null),
},
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'status', size: 'md' },
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
{ key: 'billing', label: '费用', kind: 'numeric', size: 'xl' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
{ key: 'performance', label: '速率', kind: 'numeric', size: 'lg' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
...usageDataColumns,
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm', hideable: false },
])
@@ -9,6 +9,9 @@ export type UsagePerformanceRecord = Pick<UsageCommonRecord, 'latencyDetails' |
tokenDetails: Pick<UsageListRecord['tokenDetails'], 'outputTokens'> | null
}
export type UsageModelRecord = Pick<UsageCommonRecord, 'model'> & Partial<Pick<UsageCommonRecord, 'requestedModel' | 'upstreamModel' | 'upstreamResponseModel'>>
export type UsageReasoningRecord = Pick<UsageCommonRecord, 'reasoningEffort'> & Partial<Pick<UsageCommonRecord, 'reasoningPreset' | 'subagentKind'>>
interface UsageTimingItem {
label: string
value: string
@@ -58,7 +61,7 @@ export function usageTransportTypeClass(transport?: string | null) {
return 'bg-cp-fill-tertiary text-cp-text-secondary'
}
export function usageAccountText(record: UsageCommonRecord) {
export function usageAccountText(record: Pick<UsageCommonRecord, 'accountEmail' | 'accountName' | 'accountId'>) {
return record.accountEmail || record.accountName || record.accountId || '—'
}
@@ -70,14 +73,14 @@ export function usageUserAgent(record: { userAgent?: string | null }) {
return record.userAgent || '—'
}
export function usageReasoningEffort(record: UsageCommonRecord) {
export function usageReasoningEffort(record: UsageReasoningRecord) {
const reasoningEffort = record.reasoningEffort || '—'
if (record.subagentKind)
return reasoningEffort
return record.reasoningPreset || reasoningEffort
}
export function usageModelDisplay(record: UsageCommonRecord) {
export function usageModelDisplay(record: UsageModelRecord) {
const requestedModel = record.requestedModel || ''
const upstreamModel = record.upstreamModel || ''
const storedModel = record.model || ''
@@ -1,48 +1,38 @@
<script setup lang="ts">
import type { Account } from '@/api'
import { ZDialog, ZIconButton, ZPagination } from '@codex-proxy/ui'
import { Grid2X2, RefreshCw } from '@lucide/vue'
import { computed, toRef } from 'vue'
import { ZDialog, ZIconButton } from '@codex-proxy/ui'
import { RefreshCw } from '@lucide/vue'
import { toRef } from 'vue'
import { useAccountConcurrency } from '../composables/useAccountConcurrency'
import { accountIdentityLabel } from '../utils/accountIdentity'
import AccountConcurrencyTable from './AccountConcurrencyTable.vue'
const props = defineProps<{ account: Account | null }>()
const open = defineModel<boolean>({ required: true })
const accountLabel = computed(() => props.account ? accountIdentityLabel(props.account) : '当前账号')
const { pageSize, data, entries, loading, tableLoading, error, refresh, setPage, setPageSize } = useAccountConcurrency(toRef(props, 'account'), open)
const { data, entries, loading, error, refresh } = useAccountConcurrency(toRef(props, 'account'), open)
</script>
<template>
<ZDialog v-model="open" title="并发明细" :description="`${accountLabel} · 当前正在处理的请求`" width="72rem">
<ZDialog v-model="open" title="并发明细" description="当前正在处理的请求" width="72rem">
<div class="grid min-w-0 gap-2">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-1.5 text-cp-sm" role="group" aria-label="当前占用 / 并发上限">
<Grid2X2 class="size-3.5 text-cp-text-tertiary" :stroke-width="1.8" aria-hidden="true" />
<div class="flex items-center text-cp-sm" role="group" aria-label="当前占用 / 并发上限">
<span class="whitespace-nowrap font-mono tabular-nums">
<strong class="font-emphasis text-cp-text">{{ data?.total ?? account?.capacity.usedSlots ?? '—' }}</strong>
<span class="text-cp-text-tertiary"> / {{ account?.capacity.totalSlots ?? '不限' }}</span>
</span>
</div>
<ZIconButton
aria-label="刷新并发明细" title="每 5 秒自动刷新" variant="solid" size="small"
aria-label="刷新并发明细" title="每 5 秒自动刷新" variant="plain" size="small"
:disabled="loading" :aria-busy="loading" @click="refresh"
>
<RefreshCw class="size-4" :class="{ 'animate-spin motion-reduce:animate-none': loading }" />
</ZIconButton>
</div>
<AccountConcurrencyTable
:entries="entries" :total="data?.total ?? 0" :page-size="data?.pageSize ?? pageSize"
:entries="entries"
:loading="loading && !data" :aria-busy="loading"
:empty-text="error ? '暂时无法读取并发明细' : '当前没有并发占用'"
/>
</div>
<template v-if="data && data.total > 0" #footer>
<ZPagination
class="-mt-3! w-full"
:current-page="data.page" :page-size="data.pageSize" :total="data.total" :page-sizes="[20, 50, 100]"
:disabled="tableLoading" @current-change="setPage" @size-change="setPageSize"
/>
</template>
</ZDialog>
</template>
@@ -1,14 +1,11 @@
<script setup lang="ts">
import type { AccountConcurrencyEntry } from '@/api'
import { defineTableColumns, ZTable } from '@codex-proxy/ui'
import { computed } from 'vue'
import UsageClientIpCell from '@/components/usage/UsageClientIpCell.vue'
import { formatDuration } from '@/utils/format'
const props = defineProps<{
defineProps<{
entries: AccountConcurrencyEntry[]
total: number
pageSize: number
loading: boolean
emptyText: string
}>()
@@ -20,15 +17,11 @@ const columns = defineTableColumns<AccountConcurrencyEntry>([
{ key: 'startedAtDisplay', label: '开始时间', kind: 'datetime', size: 'xl' },
{ key: 'occupiedMs', label: '占用时长', kind: 'numeric', size: 'lg', formatter: (_, row) => formatDuration(row.occupiedMs) },
])
// 按分页容量保留高度,切页与末页记录较少时不让弹窗收缩
const tableHeight = computed(() => `min(60dvh, max(16rem, calc(2.5rem + 3px + ${Math.min(props.total, props.pageSize, 10)} * var(--cp-table-row-height))))`)
</script>
<template>
<ZTable
class="min-h-0 [--cp-table-row-height:3rem]"
:style="{ height: tableHeight }"
class="h-[min(60dvh,20rem)]! min-h-0 [--cp-table-row-height:3rem]"
:columns="columns" :data="entries" row-key="leaseId"
:loading="loading" :empty-text="emptyText"
>
@@ -88,7 +88,7 @@ watch(() => props.accountId, () => {
<div class="flex items-center gap-3">
<ZButton size="small" variant="text" aria-label="设置全局提前时间" @click="router.push('/settings#reset-credits')">
<template #icon>
<Clock3 class="size-3.5 -translate-y-px" />
<Clock3 class="size-3.5" />
</template>
{{ status ? `提前 ${status.leadMinutes} 分钟` : '提前时间' }}
</ZButton>
@@ -6,17 +6,12 @@ import { getAccountConcurrency } from '@/api'
import { useRequestState } from '@/composables/useRequestState'
export function useAccountConcurrency(account: Ref<Account | null>, open: Ref<boolean>) {
const page = shallowRef(1)
const pageSize = shallowRef(20)
const data = shallowRef<AccountConcurrencyPage | null>(null)
const data = shallowRef<Pick<AccountConcurrencyPage, 'entries' | 'total'> | null>(null)
const receivedAt = shallowRef(0)
const now = shallowRef(performance.now())
const visibility = useDocumentVisibility()
const active = computed(() => open.value && !!account.value && visibility.value === 'visible')
const request = useRequestState()
const tableLoading = computed(() => request.loading.value && (
!data.value || data.value.page !== page.value || data.value.pageSize !== pageSize.value
))
let timer: ReturnType<typeof setTimeout> | undefined
const ticker = useIntervalFn(() => {
@@ -44,21 +39,35 @@ export function useAccountConcurrency(account: Ref<Account | null>, open: Ref<bo
const id = request.start(true)
request.loading.value = true
try {
const result = await getAccountConcurrency({
accountId: account.value.id,
page: page.value,
pageSize: pageSize.value,
}, { signal: request.signal, silent: true, retry: false })
const accountId = account.value.id
const loadPage = (page: number) => getAccountConcurrency({ accountId, page, pageSize: 100 }, { signal: request.signal, silent: true, retry: false })
const result = await loadPage(1)
if (!request.isCurrent(id))
return
const lastPage = Math.max(1, Math.ceil(result.total / pageSize.value))
if (page.value > lastPage) {
page.value = lastPage
return
const firstReceivedAt = performance.now()
const collected = new Map(result.entries.map(entry => [entry.leaseId, { entry, receivedAt: firstReceivedAt }]))
// 按首次总数限定本轮读取,避免持续新增占用让刷新无界延长
const pages = Math.ceil(result.total / result.pageSize)
for (let page = 2; page <= pages; page++) {
const next = await loadPage(page)
if (!request.isCurrent(id))
return
const receivedAt = performance.now()
for (const entry of next.entries)
collected.set(entry.leaseId, { entry, receivedAt })
if (!next.entries.length)
break
}
const snapshotAt = performance.now()
data.value = {
total: result.total,
entries: [...collected.values()].map(({ entry, receivedAt }) => ({
...entry,
occupiedMs: entry.occupiedMs === null ? null : entry.occupiedMs + snapshotAt - receivedAt,
})),
}
data.value = result
request.error.value = ''
receivedAt.value = performance.now()
receivedAt.value = snapshotAt
now.value = receivedAt.value
}
catch (cause: unknown) {
@@ -72,15 +81,9 @@ export function useAccountConcurrency(account: Ref<Account | null>, open: Ref<bo
}
}
watch(() => account.value?.id, () => {
page.value = 1
})
watch(pageSize, () => {
page.value = 1
})
watch([active, () => account.value?.id, page, pageSize], ([isActive, accountId], [, previousAccountId]) => {
watch([active, () => account.value?.id], ([isActive, accountId], [, previousAccountId]) => {
stop()
// 同一账号保留已确认的页快照,切页、暂停轮询和弹窗退场都不提前清空内容
// 同一账号保留已确认内容,暂停轮询和弹窗退场不提前清空
if (accountId !== previousAccountId) {
data.value = null
request.error.value = ''
@@ -92,17 +95,5 @@ export function useAccountConcurrency(account: Ref<Account | null>, open: Ref<bo
}, { immediate: true })
onScopeDispose(stop)
function setPage(value: number) {
if (page.value === value)
void refresh()
else page.value = value
}
function setPageSize(value: number) {
if (pageSize.value === value)
void refresh()
else pageSize.value = value
}
return { page, pageSize, data, entries, loading: request.loading, tableLoading, error: request.error, refresh, setPage, setPageSize }
return { data, entries, loading: request.loading, error: request.error, refresh }
}
@@ -1,50 +1,48 @@
<script setup lang="ts">
import type { PaginationState as Pagination } from '@codex-proxy/ui'
import type { KeyUsageRecord, KeyUsageRecordKind } from '@/api/modules/key-usage'
import { defineTableColumns, ZCard, ZPagination, ZSegmented, ZTable } from '@codex-proxy/ui'
import { computed } from 'vue'
import { defineTableColumns, useTableColumns, ZCard, ZPagination, ZTable, ZTableColumnSettings } from '@codex-proxy/ui'
import { usageDataColumns } from '@/components/usage/shared/columns'
import UsageBillingCell from '@/components/usage/UsageBillingCell.vue'
import UsageClientIpCell from '@/components/usage/UsageClientIpCell.vue'
import UsageLatencyCell from '@/components/usage/UsageLatencyCell.vue'
import UsageModelCell from '@/components/usage/UsageModelCell.vue'
import UsagePerformanceCell from '@/components/usage/UsagePerformanceCell.vue'
import UsageReasoningEffortCell from '@/components/usage/UsageReasoningEffortCell.vue'
import UsageRecordStatusFilter from '@/components/usage/UsageRecordStatusFilter.vue'
import UsageRouteCell from '@/components/usage/UsageRouteCell.vue'
import UsageTimeCell from '@/components/usage/UsageTimeCell.vue'
import UsageTokenCell from '@/components/usage/UsageTokenCell.vue'
import UsageTransportBadge from '@/components/usage/UsageTransportBadge.vue'
defineProps<{ rows: KeyUsageRecord[], pagination: Pagination, loading: boolean, error: string, stale: boolean }>()
defineEmits<{ pageChange: [page: number], pageSizeChange: [size: number] }>()
const kind = defineModel<KeyUsageRecordKind>('kind', { required: true })
const columns = computed(() => defineTableColumns<KeyUsageRecord>([
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'lg' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'status', size: 'md' },
{ key: 'tokenDetails', label: 'TOKEN', kind: 'numeric', size: 'xl' },
{ key: 'billing', label: '费用', kind: 'numeric', size: 'xl' },
{ key: 'latency', label: '延迟', kind: 'numeric', size: 'xl' },
{ key: 'performance', label: '速率', kind: 'numeric', size: 'lg' },
...(kind.value === 'error' ? [{ key: 'statusCode', label: '状态', kind: 'status' as const }] : []),
{ key: 'createdAt', label: '时间', kind: 'datetime' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
]))
const columns = defineTableColumns<KeyUsageRecord>(usageDataColumns)
const { visibleColumns, columnOptions, setColumnVisible, setColumnOrder, resetColumns } = useTableColumns(columns, 'key-usage-records')
</script>
<template>
<ZCard title="请求日志" description="当前请求记录">
<template #actions>
<ZSegmented v-model="kind" aria-label="请求结果" :options="[{ label: '成功请求', value: 'success' }, { label: '错误记录', value: 'error' }]" />
<div class="flex items-center gap-2">
<UsageRecordStatusFilter v-model="kind" />
<ZTableColumnSettings :options="columnOptions" @change="setColumnVisible" @reorder="setColumnOrder" @reset="resetColumns" />
</div>
</template>
<p v-if="error || stale" role="status" class="mt-0 mb-3 text-cp-sm text-cp-error-text">
{{ error || '请求日志刷新失败,暂时保留上次结果' }}
</p>
<div class="flex h-120 min-h-0 overflow-hidden">
<ZTable class="min-w-0 flex-1" :columns="columns" :data="rows" :loading="loading" scrollbar-always-visible :empty-text="error ? '请求日志加载失败,请点击顶部刷新重试' : '所选条件下暂无记录'">
<ZTable class="min-w-0 flex-1" :columns="visibleColumns" :data="rows" :loading="loading" scrollbar-always-visible :empty-text="error ? '请求日志加载失败,请点击顶部刷新重试' : '所选条件下暂无记录'">
<template #model="{ row }">
<code class="block max-w-full truncate font-mono text-cp-sm leading-none font-heavy text-cp-text">{{ row.model || '—' }}</code>
<UsageModelCell :record="row" />
</template>
<template #reasoningEffort="{ row }">
<span class="whitespace-nowrap text-cp-sm font-bold text-cp-text">{{ row.reasoningEffort || '—' }}</span>
<UsageReasoningEffortCell :record="row" />
</template>
<template #route="{ row }">
<UsageRouteCell :record="row" />
</template>
<template #upstreamTransport="{ row }">
<UsageTransportBadge :transport="row.upstreamTransport" />
@@ -65,11 +63,8 @@ const columns = computed(() => defineTableColumns<KeyUsageRecord>([
<template #performance="{ row }">
<UsagePerformanceCell :record="row" />
</template>
<template #statusCode="{ row }">
<span class="font-mono text-cp-sm font-bold text-cp-error-text">{{ row.statusCode ?? '错误' }}</span>
</template>
<template #createdAt="{ row }">
{{ row.createdAtDisplay }}
<template #createdAtDisplay="{ row }">
<UsageTimeCell :value="row.createdAtDisplay" />
</template>
<template #clientIp="{ row }">
<UsageClientIpCell :record="row" />
@@ -113,6 +113,8 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord
return {
id: options.id,
logicalOutcome: 'succeeded',
errorCode: null,
errorMessage: null,
clientApiKeyName: '演示 Key',
provider: options.provider,
authenticationKind: options.authenticationKind,
@@ -0,0 +1,131 @@
<script setup lang="ts">
import type { UsageRecordDetail } from '@/api'
import { ZButton, ZDialog } from '@codex-proxy/ui'
import { computed } from 'vue'
import { usageAccountText, usageModelDisplay, usageTransportType } from '@/components/usage/shared/presenter'
import { formatDuration } from '@/utils/format'
import { attemptOutcomeClass, attemptOutcomeText, visibleResponseText } from '../utils/detail'
import DiagnosticsDownloadButton from './DiagnosticsDownloadButton.vue'
import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
import UsageDetailFieldGrid from './UsageDetailFieldGrid.vue'
import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
const props = defineProps<{ record: UsageRecordDetail | 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 errorMessage = computed(() => {
const message = props.record?.message.trim()
return message && message !== props.record?.logicalOutcome ? message : '未记录具体错误消息'
})
const responseText = computed(() => props.record ? visibleResponseText(props.record) : '')
const metadataText = computed(() => props.record && Object.keys(props.record.metadata).length
? JSON.stringify(props.record.metadata, null, 2)
: '')
const contextItems = computed(() => [
{ label: '账号', value: props.record ? usageAccountText(props.record) : null, mono: true, fullWidth: true },
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
{ label: '总耗时', value: props.record?.latencyMsDisplay, mono: true },
{ label: '平台', value: props.record?.provider, mono: true },
{ label: '端点', value: props.record?.route, mono: true },
{ label: '请求模型', value: props.record ? usageModelDisplay(props.record).primary : null, mono: true },
{ label: '上游模型', value: props.record?.upstreamModel, mono: true },
{ label: '客户端传输', value: usageTransportType(props.record?.clientTransport), mono: true },
{ label: '上游传输', value: usageTransportType(props.record?.upstreamTransport), mono: true },
])
const identifierItems = computed(() => [
{ label: '客户端状态码', value: props.record?.clientStatusCode, mono: true },
{ label: '上游状态码', value: props.record?.upstreamStatusCode, mono: true },
{ label: '请求 ID', value: props.record?.requestId, mono: true, fullWidth: true },
{ label: '上游请求 ID', value: props.record?.upstreamRequestId, mono: true, fullWidth: true },
{ label: '响应 ID', value: props.record?.responseId, mono: true, fullWidth: true },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId, mono: true, fullWidth: true },
])
const attempts = computed(() => (props.record?.attempts ?? []).map(attempt => ({
id: attempt.id,
index: attempt.attemptIndex,
outcome: attempt.outcome,
statusCode: attempt.statusCode,
items: [
{ label: '错误码', value: attempt.providerErrorCode, mono: true },
{ label: '失败分类', value: attempt.failureClass, mono: true },
{ label: '账号', value: usageAccountText(attempt), mono: true },
{ label: '平台', value: attempt.provider, mono: true },
{ label: '上游传输', value: usageTransportType(attempt.transport), mono: true },
{ label: '耗时', value: formatDuration(attempt.latencyMs), mono: true },
{ label: '触发方式', value: attempt.trigger, mono: true },
{ label: '发送状态', value: attempt.sendState, mono: true },
],
})))
</script>
<template>
<ZDialog v-model="open" title="错误详情" description="失败原因与请求上下文" type="danger" width="64rem">
<div v-if="record" class="grid min-w-0 gap-3">
<section class="min-w-0 rounded-cp-card bg-cp-error-container px-4 py-3.5 text-cp-error-on-container">
<div class="flex items-center gap-3">
<h3 class="m-0 text-cp-sm leading-none font-heavy">
{{ record.logicalOutcome === 'incomplete' ? '响应未完成' : '请求失败' }}
</h3>
<UsageStatusCodeBadge v-if="record.statusCode !== null" :status-code="record.statusCode" />
</div>
<p class="mt-3 mb-0 wrap-anywhere whitespace-pre-wrap font-mono text-cp-sm leading-relaxed">
{{ errorMessage }}
</p>
</section>
<section class="grid min-w-0 gap-3 lg:grid-cols-2">
<section :class="panelClass">
<h3 :class="panelTitleClass">
请求上下文
</h3>
<UsageDetailFieldGrid :items="contextItems" />
</section>
<section :class="panelClass">
<h3 :class="panelTitleClass">
错误定位
</h3>
<UsageDetailFieldGrid :items="identifierItems" />
</section>
</section>
<section v-if="attempts.length || !record.attemptsComplete" :class="panelClass">
<h3 :class="panelTitleClass">
尝试链路
</h3>
<p v-if="!record.attemptsComplete" class="mt-3 mb-0 text-cp-sm text-cp-text-tertiary">
尝试记录可能不完整
</p>
<ol v-if="attempts.length" class="mt-3 mb-0 grid list-none gap-4 p-0 lg:grid-cols-2">
<li v-for="attempt in attempts" :key="attempt.id" class="min-w-0">
<div class="flex items-center gap-3 text-cp-sm font-bold">
<span>尝试 {{ attempt.index }}</span>
<span :class="attemptOutcomeClass(attempt.outcome)">{{ attemptOutcomeText(attempt.outcome) }}</span>
<UsageStatusCodeBadge :status-code="attempt.statusCode" />
</div>
<UsageDetailFieldGrid :items="attempt.items" />
</li>
</ol>
</section>
<details v-if="responseText || metadataText" :class="panelClass">
<summary class="cursor-pointer text-cp-sm font-heavy text-cp-text-secondary">
诊断数据
</summary>
<div class="mt-3 grid min-w-0 gap-3">
<UsageDetailCodePanel v-if="responseText" title="响应内容" max-height="180px" :content="responseText" />
<UsageDetailCodePanel v-if="metadataText" title="元数据" max-height="240px" :content="metadataText" />
</div>
</details>
</div>
<template #footer>
<DiagnosticsDownloadButton v-if="record" :request-id="record.requestId" :active="open" />
<ZButton type="primary" @click="open = false">
关闭
</ZButton>
</template>
</ZDialog>
</template>
@@ -18,7 +18,7 @@ import {
} from '@/components/usage/shared/presenter'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatDuration, formatInteger } from '@/utils/format'
import { displayValue, fieldLabelClass, fieldValueBaseClass, fieldValueClass, visibleRequestText, visibleResponseText } from '../utils/detail'
import { attemptOutcomeClass, attemptOutcomeText, displayValue, fieldLabelClass, fieldValueBaseClass, visibleRequestText, visibleResponseText } from '../utils/detail'
import DiagnosticsDownloadButton from './DiagnosticsDownloadButton.vue'
import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
import UsageDetailFieldGrid from './UsageDetailFieldGrid.vue'
@@ -50,8 +50,14 @@ const finalAttemptIndex = computed(() => {
const last = attempts[attempts.length - 1]
return last?.attemptIndex ?? props.record?.attemptCount
})
const overviewItems = computed(() => [
const requestItems = computed(() => [
{ label: '账号', value: accountDisplay.value, mono: true, fullWidth: true },
{ label: '状态码', value: props.record?.statusCode, mono: true },
{ label: '消息', value: props.record?.message },
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
{ label: '端点', value: props.record?.route, mono: true },
])
const overviewItems = computed(() => [
{ label: '客户端传输', value: usageTransportType(props.record?.clientTransport), mono: true },
{ label: '上游传输', value: usageTransportType(props.record?.upstreamTransport), mono: true },
{ label: '总耗时', value: props.record?.latencyMsDisplay, mono: true },
@@ -86,7 +92,6 @@ const clientUpstreamItems = computed(() => [
label: 'User-Agent',
value: props.record ? usageUserAgent(props.record) : '',
mono: true,
wrap: true,
fullWidth: true,
},
])
@@ -138,25 +143,6 @@ const attemptRows = computed<AttemptRow[]>(() =>
})),
)
function attemptOutcomeText(outcome: string) {
const labels: Record<string, string> = {
succeeded: '成功',
failed: '失败',
cancelled: '取消',
incomplete: '未完成',
running: '进行中',
}
return labels[outcome] ?? outcome
}
function attemptOutcomeClass(outcome: string) {
if (outcome === 'succeeded')
return 'text-cp-success-text'
if (outcome === 'failed')
return 'text-cp-error-text'
return 'text-cp-text-secondary'
}
const billingItems = computed(() => {
const value = billing.value
if (!value) {
@@ -314,62 +300,19 @@ const tokenDonutOption = computed<EChartsOption>(() => {
width="64rem"
>
<div v-if="record" class="grid min-w-0 gap-3">
<section :class="panelClass">
<dl
class="grid min-w-0 grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4 lg:grid-cols-[minmax(0,2fr)_100px_minmax(0,1fr)_180px]"
>
<div class="col-span-2 min-w-0 lg:col-span-1">
<dt :class="fieldLabelClass">
账号
</dt>
<dd
class="mt-1.5 mb-0 min-w-0 break-all font-mono text-cp-sm leading-snug font-heavy text-cp-text"
:title="displayValue(accountDisplay)"
>
{{ displayValue(accountDisplay) }}
</dd>
</div>
<div class="min-w-0">
<dt :class="fieldLabelClass">
状态码
</dt>
<dd :class="fieldValueClass(true)" :title="displayValue(record.statusCode)">
{{ displayValue(record.statusCode) }}
</dd>
</div>
<div class="min-w-0">
<dt :class="fieldLabelClass">
消息
</dt>
<dd :class="fieldValueClass()" :title="displayValue(record.message)">
{{ displayValue(record.message) }}
</dd>
</div>
<div class="col-span-2 min-w-0 lg:col-span-1">
<dt :class="fieldLabelClass">
时间
</dt>
<dd :class="fieldValueClass(true)" :title="displayValue(record.createdAtDisplay)">
{{ displayValue(record.createdAtDisplay) }}
</dd>
</div>
</dl>
<dl
class="mt-4 grid min-w-0 grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 lg:grid-cols-[minmax(160px,1.4fr)_repeat(5,minmax(0,1fr))]"
>
<div v-for="item in overviewItems" :key="item.label" class="min-w-0">
<dt :class="fieldLabelClass">
{{ item.label }}
</dt>
<dd :class="fieldValueClass(item.mono)" :title="displayValue(item.value)">
{{ displayValue(item.value) }}
</dd>
</div>
</dl>
<section class="grid min-w-0 gap-3 lg:grid-cols-2">
<section :class="panelClass">
<h3 :class="panelTitleClass">
请求信息
</h3>
<UsageDetailFieldGrid :items="requestItems" />
</section>
<section :class="panelClass">
<h3 :class="panelTitleClass">
传输与用量
</h3>
<UsageDetailFieldGrid :items="overviewItems" />
</section>
</section>
<section class="grid min-w-0 gap-3 lg:grid-cols-2">
+20 -13
View File
@@ -1,12 +1,15 @@
<script setup lang="ts">
import type { UsageRecordStatus } from '@/api'
import { useTableColumns, ZCard, ZDatePicker, ZIconButton, ZPageHeader, ZPagination, ZSegmented, ZTableColumnSettings } from '@codex-proxy/ui'
import type { TableColumn } from '@codex-proxy/ui'
import type { UsageListRecord, UsageRecordStatus } from '@/api'
import { useTableColumns, ZCard, ZDatePicker, ZIconButton, ZPageHeader, ZPagination, ZTableColumnSettings } from '@codex-proxy/ui'
import { CloudRain, Eye, List, Sun } from '@lucide/vue'
import { shallowRef, watch } from 'vue'
import { Eye } from '@lucide/vue'
import { computed, 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 UsageRecordStatusFilter from '@/components/usage/UsageRecordStatusFilter.vue'
import UsageErrorDetailModal from './components/UsageErrorDetailModal.vue'
import UsageFilters from './components/UsageFilters.vue'
import UsageInsightsGrid from './components/UsageInsightsGrid.vue'
import UsageRecordDetailModal from './components/UsageRecordDetailModal.vue'
@@ -14,14 +17,15 @@ import UsageSummaryCards from './components/UsageSummaryCards.vue'
import { useUsageRecordDetail } from './composables/useUsageRecordDetail'
import { useUsageRecordsTable } from './composables/useUsageRecordsTable'
import { useUsageTimeRange } from './composables/useUsageTimeRange'
import { isErrorUsageRecord } from './utils/detail'
const recordView = shallowRef<UsageRecordStatus>('success')
const { visibleColumns, columnOptions, setColumnVisible, setColumnOrder, resetColumns } = useTableColumns(usageRecordColumns, 'usage-records')
const recordViewOptions = [
{ label: '成功', value: 'success', icon: Sun },
{ label: '错误', value: 'error', icon: CloudRain },
{ label: '全部', value: 'all', icon: List },
]
const recordColumns = computed<TableColumn<UsageListRecord>[]>(() => recordView.value === 'error'
? usageRecordColumns.flatMap(column => column.key === 'route'
? [column, { key: 'errorMessage', label: '错误', kind: 'custom', size: '4xl', hideable: false }]
: [column])
: usageRecordColumns)
const { visibleColumns, columnOptions, setColumnVisible, setColumnOrder, resetColumns } = useTableColumns(recordColumns, 'usage-records')
const { timeRange, timeRangeParams, disabledDate, refreshTimeRangeEnd, latestTimeRangeParams }
= useUsageTimeRange()
@@ -48,6 +52,9 @@ const {
})
const { showDetailModal, selectedUsageRecord, handleViewDetail } = useUsageRecordDetail()
const detailComponent = computed(() => isErrorUsageRecord(selectedUsageRecord.value)
? UsageErrorDetailModal
: UsageRecordDetailModal)
watch(timeRange, () => {
refreshTimeRangeEnd()
@@ -112,7 +119,7 @@ watch(timeRange, () => {
@refresh="refreshUsageRecords"
>
<template #actions>
<ZSegmented v-model="recordView" aria-label="请求明细类型" :options="recordViewOptions" display="icon" class="w-30 shrink-0" />
<UsageRecordStatusFilter v-model="recordView" />
<ZTableColumnSettings
:options="columnOptions"
@change="setColumnVisible"
@@ -134,7 +141,7 @@ watch(timeRange, () => {
<div class="flex items-center justify-start">
<ZIconButton
size="small"
aria-label="查看使用记录详情"
:aria-label="isErrorUsageRecord(row) ? '查看错误详情' : '查看使用记录详情'"
@click="handleViewDetail(row)"
>
<Eye class="size-3.5" />
@@ -153,6 +160,6 @@ watch(timeRange, () => {
</template>
</ZCard>
<UsageRecordDetailModal v-model="showDetailModal" :record="selectedUsageRecord" />
<component :is="detailComponent" v-model="showDetailModal" :record="selectedUsageRecord" />
</div>
</template>
+23
View File
@@ -16,6 +16,29 @@ export function fieldValueClass(mono?: boolean) {
return [fieldValueBaseClass, mono ? 'font-mono tabular-nums' : undefined]
}
export function isErrorUsageRecord(record: Pick<UsageRecordDetail, 'logicalOutcome'> | null) {
return record?.logicalOutcome === 'failed' || record?.logicalOutcome === 'incomplete'
}
export function attemptOutcomeText(outcome: string) {
const labels: Record<string, string> = {
succeeded: '成功',
failed: '失败',
cancelled: '取消',
incomplete: '未完成',
running: '进行中',
}
return labels[outcome] ?? outcome
}
export function attemptOutcomeClass(outcome: string) {
if (outcome === 'succeeded')
return 'text-cp-success-text'
if (outcome === 'failed')
return 'text-cp-error-text'
return 'text-cp-text-secondary'
}
export function visibleRequestText(record: UsageRecordDetail) {
const body = record.metadata.requestBody
if (!body)