mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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() {
|
||||
"¤tPage=0",
|
||||
"&pageSize=0",
|
||||
"&pageSize=101",
|
||||
"&kind=all",
|
||||
"&kind=unknown",
|
||||
"¤tPage=-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
@@ -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 项,
|
||||
同请求量按维度标识稳定排列
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user