mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat: improve usage telemetry and account quota UI
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -554,7 +554,25 @@ pub(crate) async fn account_quota(
|
||||
.account_quota(&account_id)
|
||||
.await
|
||||
{
|
||||
Ok(data) => Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))),
|
||||
Ok(data) => {
|
||||
let quota = data.get("quota").cloned().unwrap_or(Value::Null);
|
||||
let raw = data.get("raw").cloned().unwrap_or(Value::Null);
|
||||
let quota_json = quota.to_string();
|
||||
let quota_data = quota_data("a_json, Some(Utc::now()));
|
||||
let plan_type = quota
|
||||
.get("plan_type")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(serde_json::json!({
|
||||
"quota": quota,
|
||||
"raw": raw,
|
||||
"quotaData": quota_data,
|
||||
"planType": plan_type,
|
||||
})),
|
||||
))
|
||||
}
|
||||
Err(AdminAccountError::NotFound) => Err(account_not_found()),
|
||||
Err(AdminAccountError::Inactive(status)) => Err(AdminError::new(
|
||||
StatusCode::CONFLICT,
|
||||
@@ -1082,13 +1100,18 @@ fn push_quota_window(
|
||||
.get("window_minutes")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|minutes| minutes.checked_mul(60));
|
||||
if used_percent.is_none() && reset_at.is_none() && window_seconds.is_none() {
|
||||
let label_prefix = label_prefix
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if reset_at.is_none()
|
||||
&& window_seconds.is_none()
|
||||
&& (used_percent.is_none() || label_prefix.is_none())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let base_label = quota_window_label_display(window_seconds);
|
||||
let label_display = label_prefix
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| format!("{} · {}", value.trim(), base_label))
|
||||
.map(|value| format!("{value} · {base_label}"))
|
||||
.unwrap_or(base_label);
|
||||
|
||||
windows.push(AdminAccountQuotaWindowData {
|
||||
|
||||
@@ -380,6 +380,18 @@ impl ChatDispatchService {
|
||||
.record_token_usage(&account_id, &request.model, usage)
|
||||
.await;
|
||||
}
|
||||
let mut metadata = serde_json::json!({
|
||||
"responseId": response_id,
|
||||
"stream": false,
|
||||
"transport": backend_transport_name(response.transport),
|
||||
"firstTokenMs": response.first_token_ms,
|
||||
"usage": response.usage,
|
||||
});
|
||||
if let (Some(object), Some(decision)) =
|
||||
(metadata.as_object_mut(), response.websocket_pool_decision)
|
||||
{
|
||||
object.insert("websocketPool".to_string(), decision.metadata_value());
|
||||
}
|
||||
record_response_event(ResponseUsageRecord {
|
||||
usage_records: &self.usage_records,
|
||||
request_id,
|
||||
@@ -394,13 +406,7 @@ impl ChatDispatchService {
|
||||
status_code: 200,
|
||||
level: UsageRecordLevel::Info,
|
||||
message: "v1 chat completions completed",
|
||||
metadata: serde_json::json!({
|
||||
"responseId": response_id,
|
||||
"stream": false,
|
||||
"transport": backend_transport_name(response.transport),
|
||||
"firstTokenMs": response.first_token_ms,
|
||||
"usage": response.usage,
|
||||
}),
|
||||
metadata,
|
||||
rate_limit_headers: &response.rate_limit_headers,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -75,7 +75,7 @@ use crate::{
|
||||
backend_transport_for_response_request, is_banned_auth_signal,
|
||||
is_banned_upstream_error, CodexBackendClient, CodexBackendResponse,
|
||||
CodexBackendSseStream, CodexBackendTransport, CodexClientError,
|
||||
CodexRateLimitHeaderUpdates, CodexTurnStateUpdate,
|
||||
CodexRateLimitHeaderUpdates, CodexTurnStateUpdate, WebSocketPoolDecision,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -876,6 +876,14 @@ impl ResponseDispatchService {
|
||||
response.usage,
|
||||
)
|
||||
.await;
|
||||
let mut metadata = json!({
|
||||
"responseId": response_id,
|
||||
"stream": false,
|
||||
"transport": backend_transport_name(response.transport),
|
||||
"firstTokenMs": response.first_token_ms,
|
||||
"usage": response.usage,
|
||||
});
|
||||
insert_websocket_pool_decision(&mut metadata, response.websocket_pool_decision);
|
||||
record_response_event(ResponseUsageRecord {
|
||||
usage_records: &self.usage_records,
|
||||
request_id,
|
||||
@@ -890,13 +898,7 @@ impl ResponseDispatchService {
|
||||
status_code: 200,
|
||||
level: UsageRecordLevel::Info,
|
||||
message: "v1 responses completed",
|
||||
metadata: json!({
|
||||
"responseId": response_id,
|
||||
"stream": false,
|
||||
"transport": backend_transport_name(response.transport),
|
||||
"firstTokenMs": response.first_token_ms,
|
||||
"usage": response.usage,
|
||||
}),
|
||||
metadata,
|
||||
rate_limit_headers: &response.rate_limit_headers,
|
||||
})
|
||||
.await;
|
||||
@@ -1201,6 +1203,7 @@ impl ResponseDispatchService {
|
||||
let rate_limit_headers = response.rate_limit_headers;
|
||||
let rate_limit_header_updates = response.rate_limit_header_updates;
|
||||
let turn_state_update = response.turn_state_update;
|
||||
let websocket_pool_decision = response.websocket_pool_decision;
|
||||
let turn_state = response.turn_state;
|
||||
self.cloudflare
|
||||
.capture_set_cookie_headers(&release_account_id, &set_cookie_headers)
|
||||
@@ -1375,6 +1378,7 @@ impl ResponseDispatchService {
|
||||
rate_limit_headers,
|
||||
rate_limit_header_updates,
|
||||
turn_state_update,
|
||||
websocket_pool_decision,
|
||||
turn_state,
|
||||
started_at,
|
||||
};
|
||||
@@ -2066,6 +2070,7 @@ struct LiveResponseStreamContext {
|
||||
rate_limit_headers: Vec<(String, String)>,
|
||||
rate_limit_header_updates: Option<CodexRateLimitHeaderUpdates>,
|
||||
turn_state_update: Option<CodexTurnStateUpdate>,
|
||||
websocket_pool_decision: Option<WebSocketPoolDecision>,
|
||||
turn_state: Option<String>,
|
||||
started_at: Instant,
|
||||
}
|
||||
@@ -2829,6 +2834,11 @@ fn enrich_live_response_stream_metadata(
|
||||
.entry("rateLimitHeaders".to_string())
|
||||
.or_insert_with(|| serde_json::json!(rate_limit_headers));
|
||||
}
|
||||
if let Some(decision) = context.websocket_pool_decision {
|
||||
object
|
||||
.entry("websocketPool".to_string())
|
||||
.or_insert_with(|| decision.metadata_value());
|
||||
}
|
||||
object
|
||||
.entry("requestBody".to_string())
|
||||
.or_insert_with(|| serde_json::json!(context.request));
|
||||
@@ -2910,6 +2920,15 @@ fn insert_first_token_ms(metadata: &mut Value, first_token_ms: Option<i64>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_websocket_pool_decision(metadata: &mut Value, decision: Option<WebSocketPoolDecision>) {
|
||||
let Some(decision) = decision else {
|
||||
return;
|
||||
};
|
||||
if let Some(object) = metadata.as_object_mut() {
|
||||
object.insert("websocketPool".to_string(), decision.metadata_value());
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code_for_stream_failure(failure: &ResponsesSseFailure) -> i64 {
|
||||
let code = failure
|
||||
.upstream_code
|
||||
|
||||
@@ -45,7 +45,7 @@ use super::websocket::{
|
||||
write_websocket_audit_artifact_from_env, CodexWebSocketConnection, CodexWebSocketExchangeError,
|
||||
CodexWebSocketRateLimitHeaderUpdates, CodexWebSocketTurnStateUpdate,
|
||||
};
|
||||
use super::websocket_pool::{CodexWebSocketPool, CodexWebSocketPoolKey};
|
||||
use super::websocket_pool::{CodexWebSocketPool, CodexWebSocketPoolKey, WebSocketPoolDecision};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -245,6 +245,8 @@ pub struct CodexBackendResponse {
|
||||
pub rate_limit_headers: Vec<(String, String)>,
|
||||
/// 首个有效上游 SSE/WebSocket 事件到达代理的耗时。
|
||||
pub first_token_ms: Option<i64>,
|
||||
/// WebSocket 连接池决策。
|
||||
pub websocket_pool_decision: Option<WebSocketPoolDecision>,
|
||||
}
|
||||
|
||||
/// Codex Responses 实际使用的上游传输。
|
||||
@@ -290,6 +292,8 @@ pub struct CodexBackendStreamingResponse {
|
||||
pub rate_limit_header_updates: Option<CodexRateLimitHeaderUpdates>,
|
||||
/// live stream 期间捕获的 turn-state 更新。
|
||||
pub turn_state_update: Option<CodexTurnStateUpdate>,
|
||||
/// WebSocket 连接池决策。
|
||||
pub websocket_pool_decision: Option<WebSocketPoolDecision>,
|
||||
}
|
||||
|
||||
/// Codex compact 端点响应。
|
||||
@@ -390,6 +394,7 @@ impl CodexBackendClient {
|
||||
set_cookie_headers,
|
||||
rate_limit_headers,
|
||||
first_token_ms,
|
||||
websocket_pool_decision: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -432,6 +437,7 @@ impl CodexBackendClient {
|
||||
rate_limit_headers,
|
||||
rate_limit_header_updates: None,
|
||||
turn_state_update: None,
|
||||
websocket_pool_decision: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -567,6 +573,11 @@ impl CodexBackendClient {
|
||||
_ => execute_response_create_request_with_pool(&prepared, None, started_at).await,
|
||||
}
|
||||
.map_err(websocket_exchange_error_to_client_error)?;
|
||||
log_websocket_pool_decision(
|
||||
context.request_id,
|
||||
pool_account_id.or(context.account_id),
|
||||
exchange.pool_decision,
|
||||
);
|
||||
|
||||
Ok(CodexBackendResponse {
|
||||
body: exchange.body,
|
||||
@@ -576,6 +587,7 @@ impl CodexBackendClient {
|
||||
set_cookie_headers: exchange.set_cookie_headers,
|
||||
rate_limit_headers: exchange.rate_limit_headers,
|
||||
first_token_ms: exchange.first_token_ms,
|
||||
websocket_pool_decision: exchange.pool_decision,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -610,6 +622,11 @@ impl CodexBackendClient {
|
||||
_ => execute_response_create_request_stream_with_pool(&prepared, None).await,
|
||||
}
|
||||
.map_err(websocket_exchange_error_to_client_error)?;
|
||||
log_websocket_pool_decision(
|
||||
context.request_id,
|
||||
pool_account_id.or(context.account_id),
|
||||
exchange.pool_decision,
|
||||
);
|
||||
|
||||
Ok(CodexBackendStreamingResponse {
|
||||
body: Box::pin(
|
||||
@@ -623,6 +640,7 @@ impl CodexBackendClient {
|
||||
rate_limit_headers: exchange.rate_limit_headers,
|
||||
rate_limit_header_updates: Some(exchange.rate_limit_header_updates),
|
||||
turn_state_update: Some(exchange.turn_state_update),
|
||||
websocket_pool_decision: exchange.pool_decision,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -924,6 +942,35 @@ fn update_first_token_ms(started_at: Instant, body_bytes: &[u8], first_token_ms:
|
||||
}
|
||||
}
|
||||
|
||||
fn log_websocket_pool_decision(
|
||||
request_id: &str,
|
||||
account_id: Option<&str>,
|
||||
decision: Option<WebSocketPoolDecision>,
|
||||
) {
|
||||
let Some(decision) = decision else {
|
||||
return;
|
||||
};
|
||||
let rid_short = request_id.chars().take(8).collect::<String>();
|
||||
if let Some(reason) = decision.reason() {
|
||||
tracing::info!(
|
||||
request_id = %request_id,
|
||||
rid = %rid_short,
|
||||
account_id = account_id.unwrap_or_default(),
|
||||
ws_pool = decision.kind(),
|
||||
ws_pool_reason = reason,
|
||||
"websocket pool decision"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
request_id = %request_id,
|
||||
rid = %rid_short,
|
||||
account_id = account_id.unwrap_or_default(),
|
||||
ws_pool = decision.kind(),
|
||||
"websocket pool decision"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_millis_i64(started_at: Instant) -> i64 {
|
||||
started_at.elapsed().as_millis().min(i64::MAX as u128) as i64
|
||||
}
|
||||
|
||||
@@ -25,5 +25,7 @@ pub use self::{
|
||||
build_codex_base_headers, build_codex_headers, build_ordered_codex_base_headers,
|
||||
build_ordered_codex_headers, order_headers,
|
||||
},
|
||||
websocket_pool::{CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey},
|
||||
websocket_pool::{
|
||||
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ use crate::upstream::protocol::websocket::{
|
||||
|
||||
use super::websocket_pool::{
|
||||
CodexWebSocketConnectionMetadata, CodexWebSocketPool, CodexWebSocketPoolKey, CodexWsStream,
|
||||
PooledWebSocketConnection, WebSocketPoolAcquire,
|
||||
PooledWebSocketConnection, WebSocketPoolAcquire, WebSocketPoolDecision,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -115,6 +115,8 @@ pub struct CodexWebSocketExchange {
|
||||
pub handshake_status: u16,
|
||||
/// 首个有效上游 WebSocket 事件到达代理的耗时。
|
||||
pub first_token_ms: Option<i64>,
|
||||
/// WebSocket 连接池决策。
|
||||
pub pool_decision: Option<WebSocketPoolDecision>,
|
||||
}
|
||||
|
||||
/// Responses WebSocket live SSE exchange result.
|
||||
@@ -133,6 +135,8 @@ pub struct CodexWebSocketStreamingExchange {
|
||||
pub turn_state_update: CodexWebSocketTurnStateUpdate,
|
||||
/// 打开握手响应状态码。
|
||||
pub handshake_status: u16,
|
||||
/// WebSocket 连接池决策。
|
||||
pub pool_decision: Option<WebSocketPoolDecision>,
|
||||
}
|
||||
|
||||
/// Responses WebSocket live SSE byte stream.
|
||||
@@ -442,8 +446,17 @@ pub(crate) async fn execute_response_create_request_with_pool(
|
||||
match result {
|
||||
Err(CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstFrame {
|
||||
..
|
||||
}) => execute_fresh_response_create_request(request, started_at).await,
|
||||
other => other,
|
||||
}) => {
|
||||
let mut exchange =
|
||||
execute_fresh_response_create_request(request, started_at).await?;
|
||||
exchange.pool_decision = Some(WebSocketPoolDecision::retry_after_stale_reuse());
|
||||
Ok(exchange)
|
||||
}
|
||||
Ok(mut exchange) => {
|
||||
exchange.pool_decision = Some(WebSocketPoolDecision::reuse());
|
||||
Ok(exchange)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
WebSocketPoolAcquire::FreshReserved => {
|
||||
@@ -457,10 +470,15 @@ pub(crate) async fn execute_response_create_request_with_pool(
|
||||
if result.is_err() {
|
||||
pool.discard(&key).await;
|
||||
}
|
||||
result
|
||||
result.map(|mut exchange| {
|
||||
exchange.pool_decision = Some(WebSocketPoolDecision::new());
|
||||
exchange
|
||||
})
|
||||
}
|
||||
WebSocketPoolAcquire::Bypass => {
|
||||
execute_fresh_response_create_request(request, started_at).await
|
||||
WebSocketPoolAcquire::Bypass(reason) => {
|
||||
let mut exchange = execute_fresh_response_create_request(request, started_at).await?;
|
||||
exchange.pool_decision = Some(WebSocketPoolDecision::bypass(reason));
|
||||
Ok(exchange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,8 +503,17 @@ pub(crate) async fn execute_response_create_request_stream_with_pool(
|
||||
match result {
|
||||
Err(CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstFrame {
|
||||
..
|
||||
}) => execute_fresh_response_create_request_stream(request).await,
|
||||
other => other,
|
||||
}) => {
|
||||
let mut exchange =
|
||||
execute_fresh_response_create_request_stream(request).await?;
|
||||
exchange.pool_decision = Some(WebSocketPoolDecision::retry_after_stale_reuse());
|
||||
Ok(exchange)
|
||||
}
|
||||
Ok(mut exchange) => {
|
||||
exchange.pool_decision = Some(WebSocketPoolDecision::reuse());
|
||||
Ok(exchange)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
WebSocketPoolAcquire::FreshReserved => {
|
||||
@@ -499,9 +526,16 @@ pub(crate) async fn execute_response_create_request_stream_with_pool(
|
||||
if result.is_err() {
|
||||
pool.discard(&key).await;
|
||||
}
|
||||
result
|
||||
result.map(|mut exchange| {
|
||||
exchange.pool_decision = Some(WebSocketPoolDecision::new());
|
||||
exchange
|
||||
})
|
||||
}
|
||||
WebSocketPoolAcquire::Bypass(reason) => {
|
||||
let mut exchange = execute_fresh_response_create_request_stream(request).await?;
|
||||
exchange.pool_decision = Some(WebSocketPoolDecision::bypass(reason));
|
||||
Ok(exchange)
|
||||
}
|
||||
WebSocketPoolAcquire::Bypass => execute_fresh_response_create_request_stream(request).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,6 +810,7 @@ async fn collect_websocket_response(
|
||||
rate_limit_headers: metadata.rate_limit_headers.clone(),
|
||||
handshake_status: metadata.handshake_status,
|
||||
first_token_ms,
|
||||
pool_decision: None,
|
||||
};
|
||||
return Ok((exchange, websocket, metadata));
|
||||
}
|
||||
@@ -865,6 +900,7 @@ fn stream_websocket_response(
|
||||
rate_limit_header_updates,
|
||||
turn_state_update,
|
||||
handshake_status: response_metadata.handshake_status,
|
||||
pool_decision: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::{
|
||||
};
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::{net::TcpStream, sync::Mutex, time::timeout};
|
||||
use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
|
||||
@@ -118,15 +119,15 @@ impl CodexWebSocketPool {
|
||||
let acquire = {
|
||||
let mut state = self.inner.lock().await;
|
||||
if !self.config.enabled || state.shutting_down {
|
||||
return WebSocketPoolAcquire::Bypass;
|
||||
return WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Disabled);
|
||||
}
|
||||
match state.slots.get(key) {
|
||||
Some(WebSocketPoolSlot::Busy | WebSocketPoolSlot::Checking) => {
|
||||
return WebSocketPoolAcquire::Bypass;
|
||||
return WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Busy);
|
||||
}
|
||||
Some(WebSocketPoolSlot::Idle(_)) => {
|
||||
let Some(WebSocketPoolSlot::Idle(connection)) = state.slots.remove(key) else {
|
||||
return WebSocketPoolAcquire::Bypass;
|
||||
return WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Busy);
|
||||
};
|
||||
if connection.created_at.elapsed() < self.config.max_age {
|
||||
state.slots.insert(key.clone(), WebSocketPoolSlot::Busy);
|
||||
@@ -140,7 +141,7 @@ impl CodexWebSocketPool {
|
||||
if self.config.max_per_account == 0
|
||||
|| account_slot_count(&state.slots, key.account_id()) >= self.config.max_per_account
|
||||
{
|
||||
WebSocketPoolAcquire::Bypass
|
||||
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap)
|
||||
} else {
|
||||
state.slots.insert(key.clone(), WebSocketPoolSlot::Busy);
|
||||
WebSocketPoolAcquire::FreshReserved
|
||||
@@ -406,7 +407,101 @@ enum WebSocketPoolSlot {
|
||||
pub(crate) enum WebSocketPoolAcquire {
|
||||
Reused(Box<PooledWebSocketConnection>),
|
||||
FreshReserved,
|
||||
Bypass(WebSocketPoolBypassReason),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WebSocketPoolBypassReason {
|
||||
Disabled,
|
||||
Busy,
|
||||
Cap,
|
||||
}
|
||||
|
||||
impl WebSocketPoolBypassReason {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Disabled => "disabled",
|
||||
Self::Busy => "busy",
|
||||
Self::Cap => "cap",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WebSocketPoolDecision {
|
||||
kind: WebSocketPoolDecisionKind,
|
||||
reason: Option<WebSocketPoolBypassReason>,
|
||||
}
|
||||
|
||||
impl WebSocketPoolDecision {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
kind: WebSocketPoolDecisionKind::New,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reuse() -> Self {
|
||||
Self {
|
||||
kind: WebSocketPoolDecisionKind::Reuse,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bypass(reason: WebSocketPoolBypassReason) -> Self {
|
||||
Self {
|
||||
kind: WebSocketPoolDecisionKind::Bypass,
|
||||
reason: Some(reason),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retry_after_stale_reuse() -> Self {
|
||||
Self {
|
||||
kind: WebSocketPoolDecisionKind::RetryAfterStaleReuse,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(self) -> &'static str {
|
||||
self.kind.as_str()
|
||||
}
|
||||
|
||||
pub fn reason(self) -> Option<&'static str> {
|
||||
self.reason.map(WebSocketPoolBypassReason::as_str)
|
||||
}
|
||||
|
||||
pub fn metadata_value(self) -> Value {
|
||||
let mut value = json!({ "kind": self.kind() });
|
||||
if let (Some(object), Some(reason)) = (value.as_object_mut(), self.reason()) {
|
||||
object.insert("reason".to_string(), Value::String(reason.to_string()));
|
||||
}
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebSocketPoolDecision {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WebSocketPoolDecisionKind {
|
||||
New,
|
||||
Reuse,
|
||||
Bypass,
|
||||
RetryAfterStaleReuse,
|
||||
}
|
||||
|
||||
impl WebSocketPoolDecisionKind {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::New => "new",
|
||||
Self::Reuse => "reuse",
|
||||
Self::Bypass => "bypass",
|
||||
Self::RetryAfterStaleReuse => "retry_after_stale_reuse",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn account_slot_count(
|
||||
|
||||
@@ -98,6 +98,19 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
|
||||
"window_minutes": 300,
|
||||
"limit_reached": false
|
||||
}
|
||||
}, {
|
||||
"source": "core",
|
||||
"limit_name": null,
|
||||
"metered_feature": null,
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"blocked": false,
|
||||
"primary": {
|
||||
"used_percent": 0,
|
||||
"remaining_percent": 100,
|
||||
"limit_reached": false
|
||||
},
|
||||
"secondary": null
|
||||
}],
|
||||
"monthly_limit": {
|
||||
"key": "spend-control-monthly",
|
||||
@@ -168,6 +181,7 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() {
|
||||
let body = response_json(response).await;
|
||||
let item = &body["data"]["items"][0];
|
||||
assert_eq!(item["usage"]["requestCount"], 41);
|
||||
assert_eq!(item["quota"]["windows"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(item["quota"]["windows"][0]["labelDisplay"], "月限额");
|
||||
assert_eq!(item["quota"]["windows"][0]["group"], "monthly");
|
||||
assert_eq!(item["quota"]["windows"][1]["labelDisplay"], "5小时限额");
|
||||
|
||||
@@ -77,4 +77,14 @@ async fn admin_account_quota_should_send_usage_cookie() {
|
||||
(response.status(), cookie_header),
|
||||
(StatusCode::OK, Some("cf_clearance=admin-quota"))
|
||||
);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["data"]["planType"], "plus");
|
||||
assert_eq!(
|
||||
body["data"]["quotaData"]["windows"][0]["labelDisplay"],
|
||||
"5小时限额"
|
||||
);
|
||||
assert_eq!(
|
||||
body["data"]["quotaData"]["windows"][0]["group"],
|
||||
"shortTerm"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,6 +202,7 @@ async fn chat_completions_with_user_should_use_and_reuse_websocket() {
|
||||
.as_str()
|
||||
.is_some_and(|key| key.starts_with("cp_")));
|
||||
assert_eq!(metadata["transport"], "websocket");
|
||||
assert_eq!(metadata["websocketPool"]["kind"], "reuse");
|
||||
assert!(
|
||||
metadata["firstTokenMs"]
|
||||
.as_i64()
|
||||
|
||||
@@ -253,6 +253,7 @@ async fn responses_non_stream_should_record_websocket_transport_metadata() {
|
||||
let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap();
|
||||
assert_eq!(metadata["stream"], false);
|
||||
assert_eq!(metadata["transport"], "websocket");
|
||||
assert_eq!(metadata["websocketPool"]["kind"], "new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -69,6 +69,8 @@ async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and
|
||||
|
||||
assert!(first.body.contains("resp_pool_first"));
|
||||
assert!(second.body.contains("resp_pool_second"));
|
||||
assert_eq!(first.websocket_pool_decision.unwrap().kind(), "new");
|
||||
assert_eq!(second.websocket_pool_decision.unwrap().kind(), "reuse");
|
||||
assert_eq!(accepted_connections.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
@@ -232,6 +234,16 @@ async fn websocket_pool_should_bypass_busy_key_with_one_shot_connections() {
|
||||
|
||||
assert!(second.body.contains("resp_busy_second"));
|
||||
assert!(third.body.contains("resp_busy_third"));
|
||||
assert_eq!(second.websocket_pool_decision.unwrap().kind(), "bypass");
|
||||
assert_eq!(
|
||||
second.websocket_pool_decision.unwrap().reason(),
|
||||
Some("busy")
|
||||
);
|
||||
assert_eq!(third.websocket_pool_decision.unwrap().kind(), "bypass");
|
||||
assert_eq!(
|
||||
third.websocket_pool_decision.unwrap().reason(),
|
||||
Some("busy")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -293,6 +305,14 @@ async fn websocket_pool_should_bypass_new_keys_after_account_cap() {
|
||||
assert!(first.body.contains("resp_cap_first"));
|
||||
assert!(second.body.contains("resp_cap_second"));
|
||||
assert!(third.body.contains("resp_cap_third"));
|
||||
assert_eq!(first.websocket_pool_decision.unwrap().kind(), "new");
|
||||
assert_eq!(second.websocket_pool_decision.unwrap().kind(), "bypass");
|
||||
assert_eq!(
|
||||
second.websocket_pool_decision.unwrap().reason(),
|
||||
Some("cap")
|
||||
);
|
||||
assert_eq!(third.websocket_pool_decision.unwrap().kind(), "bypass");
|
||||
assert_eq!(third.websocket_pool_decision.unwrap().reason(), Some("cap"));
|
||||
assert_eq!(accepted_connections.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseScrollbar from './BaseScrollbar.vue'
|
||||
|
||||
type ModalVariant = 'default' | 'info' | 'warning' | 'danger' | 'success'
|
||||
|
||||
@@ -12,6 +13,9 @@ const props = defineProps<{
|
||||
width?: string | number
|
||||
variant?: ModalVariant
|
||||
closeDisabled?: boolean
|
||||
scrollable?: boolean
|
||||
bodyMaxHeight?: string
|
||||
bodyViewClass?: string
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
@@ -22,6 +26,11 @@ const modalStyle = computed(() => ({
|
||||
width: typeof props.width === 'number' ? `${props.width}px` : (props.width ?? undefined),
|
||||
maxWidth: '100%',
|
||||
}))
|
||||
const bodyClass = computed(() => [
|
||||
'p-7',
|
||||
props.description || variant.value !== 'default' ? 'pt-5' : 'pt-6',
|
||||
])
|
||||
const scrollViewClass = computed(() => ['pr-4', props.bodyViewClass].filter(Boolean).join(' '))
|
||||
|
||||
const iconMap = {
|
||||
default: Info,
|
||||
@@ -103,8 +112,16 @@ function closeModal() {
|
||||
<X :size="16" />
|
||||
</BaseButton>
|
||||
</header>
|
||||
<div class="p-7" :class="description || variant !== 'default' ? 'pt-5' : 'pt-6'">
|
||||
<slot />
|
||||
<div :class="bodyClass">
|
||||
<BaseScrollbar
|
||||
v-if="scrollable"
|
||||
class="-mr-4"
|
||||
:max-height="bodyMaxHeight"
|
||||
:view-class="scrollViewClass"
|
||||
>
|
||||
<slot />
|
||||
</BaseScrollbar>
|
||||
<slot v-else />
|
||||
</div>
|
||||
<footer class="flex justify-end gap-3 px-7 pb-7">
|
||||
<slot name="footer">
|
||||
|
||||
@@ -29,6 +29,8 @@ const thumbTop = shallowRef(0)
|
||||
const horizontalThumbWidth = shallowRef(0)
|
||||
const horizontalThumbLeft = shallowRef(0)
|
||||
const visible = shallowRef(false)
|
||||
const verticalTrackHovering = shallowRef(false)
|
||||
const horizontalTrackHovering = shallowRef(false)
|
||||
const dragging = shallowRef(false)
|
||||
const horizontalDragging = shallowRef(false)
|
||||
|
||||
@@ -36,13 +38,18 @@ let dragStartY = 0
|
||||
let dragStartScrollTop = 0
|
||||
let horizontalDragStartX = 0
|
||||
let horizontalDragStartScrollLeft = 0
|
||||
const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollbar, 900, {
|
||||
const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollbar, 1600, {
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
const canScrollY = computed(() => thumbHeight.value > 0)
|
||||
const canScrollX = computed(() => horizontalThumbWidth.value > 0)
|
||||
const scrollbarVisible = computed(() => dragging.value || horizontalDragging.value || visible.value)
|
||||
const verticalScrollbarVisible = computed(
|
||||
() => dragging.value || verticalTrackHovering.value || visible.value,
|
||||
)
|
||||
const horizontalScrollbarVisible = computed(
|
||||
() => horizontalDragging.value || horizontalTrackHovering.value || visible.value,
|
||||
)
|
||||
const thumbStyle = computed(() => ({
|
||||
height: `${thumbHeight.value}px`,
|
||||
transform: `translateY(${thumbTop.value}px)`,
|
||||
@@ -100,18 +107,12 @@ function clearHideTimer() {
|
||||
|
||||
function scheduleHideScrollbar() {
|
||||
clearHideTimer()
|
||||
if (dragging.value || horizontalDragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
startHideTimer()
|
||||
}
|
||||
|
||||
function hideScrollbar() {
|
||||
clearHideTimer()
|
||||
if (!dragging.value && !horizontalDragging.value) {
|
||||
visible.value = false
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function activateScrollbar() {
|
||||
@@ -119,6 +120,28 @@ function activateScrollbar() {
|
||||
scheduleHideScrollbar()
|
||||
}
|
||||
|
||||
type ScrollbarAxis = 'vertical' | 'horizontal'
|
||||
|
||||
function setTrackHovering(axis: ScrollbarAxis, hovering: boolean) {
|
||||
if (axis === 'vertical') {
|
||||
verticalTrackHovering.value = hovering
|
||||
return
|
||||
}
|
||||
|
||||
horizontalTrackHovering.value = hovering
|
||||
}
|
||||
|
||||
function handleTrackMouseEnter(axis: ScrollbarAxis) {
|
||||
setTrackHovering(axis, true)
|
||||
showScrollbar()
|
||||
scheduleHideScrollbar()
|
||||
}
|
||||
|
||||
function handleTrackMouseLeave(axis: ScrollbarAxis) {
|
||||
setTrackHovering(axis, false)
|
||||
scheduleHideScrollbar()
|
||||
}
|
||||
|
||||
function update() {
|
||||
const wrap = wrapRef.value
|
||||
if (!wrap) {
|
||||
@@ -343,6 +366,7 @@ defineExpose({
|
||||
:class="rootClasses"
|
||||
:style="{ maxHeight }"
|
||||
@mouseenter="activateScrollbar"
|
||||
@mousemove="activateScrollbar"
|
||||
@mouseleave="hideScrollbar"
|
||||
>
|
||||
<div ref="wrap" :class="wrapClasses">
|
||||
@@ -353,9 +377,10 @@ defineExpose({
|
||||
|
||||
<div
|
||||
v-show="canScrollY"
|
||||
class="absolute top-1 right-1 z-10 w-1.5 rounded-full transition-opacity duration-200"
|
||||
:class="[verticalTrackClass, scrollbarVisible ? 'opacity-100' : 'opacity-0']"
|
||||
@mouseenter="activateScrollbar"
|
||||
class="absolute top-1 right-1 z-40 w-1.5 rounded-full transition-opacity duration-200"
|
||||
:class="[verticalTrackClass, verticalScrollbarVisible ? 'opacity-100' : 'opacity-0']"
|
||||
@mouseenter="handleTrackMouseEnter('vertical')"
|
||||
@mouseleave="handleTrackMouseLeave('vertical')"
|
||||
@pointerdown="handleTrackPointerDown"
|
||||
>
|
||||
<div
|
||||
@@ -368,9 +393,10 @@ defineExpose({
|
||||
|
||||
<div
|
||||
v-show="canScrollX"
|
||||
class="absolute bottom-1 left-1 z-10 h-1.5 rounded-full transition-opacity duration-200"
|
||||
:class="[horizontalTrackClass, scrollbarVisible ? 'opacity-100' : 'opacity-0']"
|
||||
@mouseenter="activateScrollbar"
|
||||
class="absolute bottom-1 left-1 z-40 h-1.5 rounded-full transition-opacity duration-200"
|
||||
:class="[horizontalTrackClass, horizontalScrollbarVisible ? 'opacity-100' : 'opacity-0']"
|
||||
@mouseenter="handleTrackMouseEnter('horizontal')"
|
||||
@mouseleave="handleTrackMouseLeave('horizontal')"
|
||||
@pointerdown="handleHorizontalTrackPointerDown"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useResizeObserver } from '@vueuse/core'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, nextTick, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import BaseEmpty from '../BaseEmpty.vue'
|
||||
@@ -91,22 +90,9 @@ const pageSizeModel = computed({
|
||||
const headerWrapRef = useTemplateRef<HTMLDivElement>('headerWrap')
|
||||
const bodyScrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('bodyScrollbar')
|
||||
const tableViewRef = useTemplateRef<HTMLTableElement>('tableView')
|
||||
const horizontalThumbWidth = shallowRef(0)
|
||||
const horizontalThumbLeft = shallowRef(0)
|
||||
const horizontalHovering = shallowRef(false)
|
||||
const horizontalDragging = shallowRef(false)
|
||||
const horizontalScrolled = shallowRef(false)
|
||||
const horizontalCanScrollRight = shallowRef(false)
|
||||
|
||||
let horizontalDragStartX = 0
|
||||
let horizontalDragStartScrollLeft = 0
|
||||
|
||||
const canScrollX = computed(() => horizontalThumbWidth.value > 0)
|
||||
const horizontalThumbStyle = computed(() => ({
|
||||
width: `${horizontalThumbWidth.value}px`,
|
||||
transform: `translateX(${horizontalThumbLeft.value}px)`,
|
||||
}))
|
||||
|
||||
const pagerItems = computed(() => getPagerItems(totalPages.value, currentPage.value))
|
||||
|
||||
function fixedHeaderClass(column: BaseTableColumn) {
|
||||
@@ -178,16 +164,8 @@ function rowClass(row: TableRow, index: number) {
|
||||
]
|
||||
}
|
||||
|
||||
function horizontalTrackWidth(wrap: HTMLElement) {
|
||||
return clamp(wrap.clientWidth - 8, 0, Number.POSITIVE_INFINITY)
|
||||
}
|
||||
|
||||
function maxScrollLeft(wrap: HTMLElement) {
|
||||
return clamp(wrap.scrollWidth - wrap.clientWidth, 0, Number.POSITIVE_INFINITY)
|
||||
}
|
||||
|
||||
function maxHorizontalThumbLeft(wrap: HTMLElement) {
|
||||
return clamp(horizontalTrackWidth(wrap) - horizontalThumbWidth.value, 0, Number.POSITIVE_INFINITY)
|
||||
return Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
|
||||
}
|
||||
|
||||
function scrollWrap() {
|
||||
@@ -195,8 +173,6 @@ function scrollWrap() {
|
||||
}
|
||||
|
||||
function resetHorizontalScrollbar() {
|
||||
horizontalThumbWidth.value = 0
|
||||
horizontalThumbLeft.value = 0
|
||||
horizontalScrolled.value = false
|
||||
horizontalCanScrollRight.value = false
|
||||
}
|
||||
@@ -209,18 +185,11 @@ function updateHorizontalScrollbar() {
|
||||
}
|
||||
|
||||
const scrollRange = maxScrollLeft(wrap)
|
||||
const trackWidth = horizontalTrackWidth(wrap)
|
||||
if (scrollRange <= 0 || trackWidth <= 0) {
|
||||
if (scrollRange <= 0) {
|
||||
resetHorizontalScrollbar()
|
||||
return
|
||||
}
|
||||
|
||||
horizontalThumbWidth.value = clamp(
|
||||
trackWidth * (wrap.clientWidth / wrap.scrollWidth),
|
||||
32,
|
||||
trackWidth,
|
||||
)
|
||||
horizontalThumbLeft.value = (wrap.scrollLeft / scrollRange) * maxHorizontalThumbLeft(wrap)
|
||||
horizontalScrolled.value = wrap.scrollLeft > 0
|
||||
horizontalCanScrollRight.value = wrap.scrollLeft < scrollRange - 1
|
||||
}
|
||||
@@ -233,65 +202,6 @@ function handleTableScroll() {
|
||||
updateHorizontalScrollbar()
|
||||
}
|
||||
|
||||
function handleHorizontalTrackPointerDown(event: PointerEvent) {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
const wrap = scrollWrap()
|
||||
if (!wrap) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const nextThumbLeft = event.clientX - rect.left - horizontalThumbWidth.value / 2
|
||||
const scrollRange = maxScrollLeft(wrap)
|
||||
const thumbRange = maxHorizontalThumbLeft(wrap)
|
||||
wrap.scrollLeft =
|
||||
thumbRange > 0 ? (clamp(nextThumbLeft, 0, thumbRange) / thumbRange) * scrollRange : 0
|
||||
}
|
||||
|
||||
function handleHorizontalThumbPointerDown(event: PointerEvent) {
|
||||
const wrap = scrollWrap()
|
||||
if (!wrap) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
horizontalDragging.value = true
|
||||
horizontalDragStartX = event.clientX
|
||||
horizontalDragStartScrollLeft = wrap.scrollLeft
|
||||
}
|
||||
|
||||
function handleHorizontalThumbPointerMove(event: PointerEvent) {
|
||||
if (!horizontalDragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const wrap = scrollWrap()
|
||||
if (!wrap) {
|
||||
return
|
||||
}
|
||||
|
||||
const thumbRange = maxHorizontalThumbLeft(wrap)
|
||||
if (thumbRange <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const scrollRange = maxScrollLeft(wrap)
|
||||
wrap.scrollLeft =
|
||||
horizontalDragStartScrollLeft +
|
||||
((event.clientX - horizontalDragStartX) / thumbRange) * scrollRange
|
||||
}
|
||||
|
||||
function handleHorizontalThumbPointerUp() {
|
||||
if (!horizontalDragging.value) {
|
||||
return
|
||||
}
|
||||
|
||||
horizontalDragging.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
updateHorizontalScrollbar()
|
||||
@@ -311,8 +221,6 @@ watch(
|
||||
updateHorizontalScrollbar()
|
||||
},
|
||||
)
|
||||
useEventListener(document, 'pointermove', handleHorizontalThumbPointerMove)
|
||||
useEventListener(document, 'pointerup', handleHorizontalThumbPointerUp)
|
||||
|
||||
function isLastColumn(index: number) {
|
||||
return index === computedColumns.value.length - 1
|
||||
@@ -352,11 +260,7 @@ function paginationPageClass(page: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full min-h-0 w-full max-w-full flex-col overflow-hidden"
|
||||
@mouseenter="horizontalHovering = true"
|
||||
@mouseleave="horizontalHovering = false"
|
||||
>
|
||||
<div class="flex h-full min-h-0 w-full max-w-full flex-col overflow-hidden">
|
||||
<div v-loading="loading" class="relative flex min-h-0 max-w-full flex-1 overflow-hidden pb-3">
|
||||
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden">
|
||||
<div ref="headerWrap" class="max-w-full overflow-hidden">
|
||||
@@ -404,6 +308,7 @@ function paginationPageClass(page: number) {
|
||||
v-if="hasRows"
|
||||
ref="bodyScrollbar"
|
||||
class="min-h-0 flex-1"
|
||||
horizontal
|
||||
:max-height="maxHeight"
|
||||
@scroll="handleTableScroll"
|
||||
>
|
||||
@@ -467,20 +372,6 @@ function paginationPageClass(page: number) {
|
||||
<BaseEmpty v-if="!loading" :title="emptyText" plain class="w-full max-w-80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="canScrollX"
|
||||
class="absolute right-1 bottom-1 left-1 z-30 h-1.5 rounded-full transition-opacity duration-200"
|
||||
:class="horizontalHovering || horizontalDragging ? 'opacity-100' : 'opacity-0'"
|
||||
@pointerdown="handleHorizontalTrackPointerDown"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
|
||||
:style="horizontalThumbStyle"
|
||||
@pointerdown="handleHorizontalThumbPointerDown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer
|
||||
|
||||
@@ -42,7 +42,7 @@ function quotaWindowBarClass(window?: any) {
|
||||
<div
|
||||
v-for="window in quotaWindows"
|
||||
:key="window.key"
|
||||
class="min-w-0 border-b border-slate-200/70 pb-2 last:border-b-0 last:pb-0"
|
||||
class="min-w-0 pb-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div
|
||||
class="mb-1.5 flex items-center justify-between gap-2 text-[11px] leading-none font-[760]"
|
||||
|
||||
@@ -394,8 +394,18 @@ export function useAccountMutations(options: {
|
||||
refreshingQuotaAccountIds.value = new Set(refreshingQuotaAccountIds.value).add(accountId)
|
||||
try {
|
||||
await withMinimumDuration(async () => {
|
||||
await getAccountQuota({ id: accountId })
|
||||
await loadAccounts()
|
||||
const result = await getAccountQuota({ id: accountId })
|
||||
if (result?.quotaData) {
|
||||
accounts.value = accounts.value.map((account) =>
|
||||
account.id === accountId
|
||||
? {
|
||||
...account,
|
||||
quota: result.quotaData,
|
||||
planType: result.planType ?? account.planType,
|
||||
}
|
||||
: account,
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh account quota:', error)
|
||||
|
||||
@@ -38,7 +38,6 @@ const modelDisplay = computed(() => usageModelDisplay(props.record))
|
||||
const tokenDetails = computed(() => usageTokenDetails(props.record))
|
||||
const costDetails = computed(() => usageCostDetails(props.record))
|
||||
|
||||
const scrollViewClass = 'grid gap-3 pr-3'
|
||||
const panelClass = 'rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5'
|
||||
const panelTitleClass = 'm-0 text-[12px] leading-none font-[780] text-(--cp-text-secondary)'
|
||||
const fieldLabelClass = 'text-[11px] leading-none font-bold text-(--cp-text-muted)'
|
||||
@@ -217,162 +216,161 @@ function themeColor(name: string, fallback: string) {
|
||||
description="查看单次网关请求的模型、链路、Token、费用与上游响应。"
|
||||
variant="info"
|
||||
width="960px"
|
||||
scrollable
|
||||
body-max-height="min(76vh,820px)"
|
||||
body-view-class="grid gap-3"
|
||||
>
|
||||
<div v-if="record" class="flex max-h-[min(76vh,820px)] flex-col overflow-hidden">
|
||||
<BaseScrollbar :view-class="scrollViewClass" max-height="min(76vh,820px)">
|
||||
<section :class="panelClass">
|
||||
<div class="min-w-0">
|
||||
<span :class="fieldLabelClass">账号</span>
|
||||
<p
|
||||
class="mt-1.5 mb-0 truncate font-mono text-[13px] leading-none font-[760] text-(--cp-text-primary)"
|
||||
:title="displayValue(accountDisplay)"
|
||||
>
|
||||
{{ displayValue(accountDisplay) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl
|
||||
class="mt-4 grid grid-cols-2 gap-x-5 gap-y-3 lg:grid-cols-[120px_120px_repeat(5,minmax(0,1fr))]"
|
||||
<template v-if="record">
|
||||
<section :class="panelClass">
|
||||
<div class="min-w-0">
|
||||
<span :class="fieldLabelClass">账号</span>
|
||||
<p
|
||||
class="mt-1.5 mb-0 truncate font-mono text-[13px] leading-none font-[760] text-(--cp-text-primary)"
|
||||
:title="displayValue(accountDisplay)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<dt :class="fieldLabelClass">结果</dt>
|
||||
<dd class="mt-1.5 mb-0">
|
||||
<UsageLevelBadge :level="record.level" />
|
||||
</dd>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<dt :class="fieldLabelClass">状态码</dt>
|
||||
<dd class="mt-1.5 mb-0">
|
||||
<UsageStatusCodeBadge :status-code="record.statusCode" />
|
||||
</dd>
|
||||
</div>
|
||||
{{ displayValue(accountDisplay) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in overviewItems"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
:class="item.wide ? 'col-span-2 lg:col-span-2' : undefined"
|
||||
>
|
||||
<dt :class="fieldLabelClass">{{ item.label }}</dt>
|
||||
<dd :class="fieldValueClass(item.mono)" :title="displayValue(item.value)">
|
||||
{{ displayValue(item.value) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-3 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<div class="flex min-h-0 flex-col gap-3">
|
||||
<section :class="panelClass">
|
||||
<h3 :class="panelTitleClass">{{ modelRouteGroup.title }}</h3>
|
||||
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div v-for="item in modelRouteGroup.items" :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>
|
||||
|
||||
<section :class="[panelClass, 'flex min-h-0 flex-1 flex-col']">
|
||||
<h3 :class="panelTitleClass">Token</h3>
|
||||
<div
|
||||
class="mt-3 grid min-h-38 flex-1 grid-cols-1 content-center items-center gap-3 sm:grid-cols-[150px_minmax(0,1fr)]"
|
||||
>
|
||||
<div class="relative mx-auto w-38 sm:mx-0">
|
||||
<BaseChart :option="tokenDonutOption" :height="152" />
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div class="grid text-center">
|
||||
<span class="text-[11px] leading-none font-bold text-(--cp-text-muted)">
|
||||
总计
|
||||
</span>
|
||||
<strong
|
||||
class="mt-1 font-mono text-[16px] leading-none font-extrabold tabular-nums text-(--cp-text-primary)"
|
||||
>
|
||||
{{ tokenDetails.totalTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div v-for="item in tokenChartItems" :key="item.label" class="min-w-0">
|
||||
<dt class="flex min-w-0 items-center gap-1.5" :class="fieldLabelClass">
|
||||
<i
|
||||
class="size-1.75 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</dt>
|
||||
<dd
|
||||
:class="[fieldValueBaseClass, 'font-mono tabular-nums']"
|
||||
:title="displayValue(item.display)"
|
||||
>
|
||||
{{ displayValue(item.display) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-col gap-3">
|
||||
<section :class="[panelClass, 'flex-1']">
|
||||
<h3 :class="panelTitleClass">{{ clientUpstreamGroup.title }}</h3>
|
||||
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div v-for="item in clientUpstreamGroup.items" :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>
|
||||
|
||||
<section :class="panelClass">
|
||||
<h3 :class="panelTitleClass">费用</h3>
|
||||
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div v-for="item in costItems" :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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="requestText || responseText"
|
||||
class="grid min-h-0 grid-cols-1 gap-3 lg:grid-cols-2"
|
||||
<dl
|
||||
class="mt-4 grid grid-cols-2 gap-x-5 gap-y-3 lg:grid-cols-[120px_120px_repeat(5,minmax(0,1fr))]"
|
||||
>
|
||||
<div v-if="requestText" :class="[panelClass, 'min-h-0']">
|
||||
<h3 class="mb-3" :class="panelTitleClass">请求内容</h3>
|
||||
<BaseScrollbar max-height="180px" :view-class="codeBlockViewClass">
|
||||
<pre :class="codeBlockClass">{{ requestText }}</pre>
|
||||
</BaseScrollbar>
|
||||
<div class="min-w-0">
|
||||
<dt :class="fieldLabelClass">结果</dt>
|
||||
<dd class="mt-1.5 mb-0">
|
||||
<UsageLevelBadge :level="record.level" />
|
||||
</dd>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<dt :class="fieldLabelClass">状态码</dt>
|
||||
<dd class="mt-1.5 mb-0">
|
||||
<UsageStatusCodeBadge :status-code="record.statusCode" />
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="responseText" :class="[panelClass, 'min-h-0']">
|
||||
<h3 class="mb-3" :class="panelTitleClass">响应内容</h3>
|
||||
<BaseScrollbar max-height="180px" :view-class="codeBlockViewClass">
|
||||
<pre :class="codeBlockClass">{{ responseText }}</pre>
|
||||
</BaseScrollbar>
|
||||
<div
|
||||
v-for="item in overviewItems"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
:class="item.wide ? 'col-span-2 lg:col-span-2' : undefined"
|
||||
>
|
||||
<dt :class="fieldLabelClass">{{ item.label }}</dt>
|
||||
<dd :class="fieldValueClass(item.mono)" :title="displayValue(item.value)">
|
||||
{{ displayValue(item.value) }}
|
||||
</dd>
|
||||
</div>
|
||||
</section>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="record.metadata" :class="[panelClass, 'min-h-0']">
|
||||
<h3 class="mb-3" :class="panelTitleClass">元数据</h3>
|
||||
<BaseScrollbar max-height="min(32vh, 340px)" :view-class="codeBlockViewClass">
|
||||
<pre :class="codeBlockClass">{{ JSON.stringify(record.metadata, null, 2) }}</pre>
|
||||
<section class="grid gap-3 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<div class="flex min-h-0 flex-col gap-3">
|
||||
<section :class="panelClass">
|
||||
<h3 :class="panelTitleClass">{{ modelRouteGroup.title }}</h3>
|
||||
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div v-for="item in modelRouteGroup.items" :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>
|
||||
|
||||
<section :class="[panelClass, 'flex min-h-0 flex-1 flex-col']">
|
||||
<h3 :class="panelTitleClass">Token</h3>
|
||||
<div
|
||||
class="mt-3 grid min-h-38 flex-1 grid-cols-1 content-center items-center gap-3 sm:grid-cols-[150px_minmax(0,1fr)]"
|
||||
>
|
||||
<div class="relative mx-auto w-38 sm:mx-0">
|
||||
<BaseChart :option="tokenDonutOption" :height="152" />
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div class="grid text-center">
|
||||
<span class="text-[11px] leading-none font-bold text-(--cp-text-muted)">
|
||||
总计
|
||||
</span>
|
||||
<strong
|
||||
class="mt-1 font-mono text-[16px] leading-none font-extrabold tabular-nums text-(--cp-text-primary)"
|
||||
>
|
||||
{{ tokenDetails.totalTokensDisplay }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div v-for="item in tokenChartItems" :key="item.label" class="min-w-0">
|
||||
<dt class="flex min-w-0 items-center gap-1.5" :class="fieldLabelClass">
|
||||
<i
|
||||
class="size-1.75 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</dt>
|
||||
<dd
|
||||
:class="[fieldValueBaseClass, 'font-mono tabular-nums']"
|
||||
:title="displayValue(item.display)"
|
||||
>
|
||||
{{ displayValue(item.display) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-col gap-3">
|
||||
<section :class="[panelClass, 'flex-1']">
|
||||
<h3 :class="panelTitleClass">{{ clientUpstreamGroup.title }}</h3>
|
||||
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div v-for="item in clientUpstreamGroup.items" :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>
|
||||
|
||||
<section :class="panelClass">
|
||||
<h3 :class="panelTitleClass">费用</h3>
|
||||
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div v-for="item in costItems" :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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="requestText || responseText"
|
||||
class="grid min-h-0 grid-cols-1 gap-3 lg:grid-cols-2"
|
||||
>
|
||||
<div v-if="requestText" :class="[panelClass, 'min-h-0']">
|
||||
<h3 class="mb-3" :class="panelTitleClass">请求内容</h3>
|
||||
<BaseScrollbar max-height="180px" :view-class="codeBlockViewClass">
|
||||
<pre :class="codeBlockClass">{{ requestText }}</pre>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
</BaseScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="responseText" :class="[panelClass, 'min-h-0']">
|
||||
<h3 class="mb-3" :class="panelTitleClass">响应内容</h3>
|
||||
<BaseScrollbar max-height="180px" :view-class="codeBlockViewClass">
|
||||
<pre :class="codeBlockClass">{{ responseText }}</pre>
|
||||
</BaseScrollbar>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="record.metadata" :class="[panelClass, 'min-h-0']">
|
||||
<h3 class="mb-3" :class="panelTitleClass">元数据</h3>
|
||||
<BaseScrollbar max-height="min(32vh, 340px)" :view-class="codeBlockViewClass">
|
||||
<pre :class="codeBlockClass">{{ JSON.stringify(record.metadata, null, 2) }}</pre>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<BaseButton variant="primary" @click="open = false">关闭</BaseButton>
|
||||
|
||||
@@ -127,7 +127,7 @@ watch(timeRange, () => {
|
||||
:padded="false"
|
||||
class="mt-5 flex flex-col"
|
||||
header-class="px-5 pt-4"
|
||||
body-class="flex h-[520px] min-h-0 px-5 pt-3 pb-4"
|
||||
body-class="flex flex-col px-5 pt-3 pb-4"
|
||||
>
|
||||
<template #header>
|
||||
<div class="grid gap-3">
|
||||
@@ -157,7 +157,7 @@ watch(timeRange, () => {
|
||||
|
||||
<template #body>
|
||||
<BaseTable
|
||||
class="min-h-0 flex-1"
|
||||
class="min-h-[520px] flex-1"
|
||||
:columns="usageRecordColumns"
|
||||
:rows="records"
|
||||
:loading="loading"
|
||||
|
||||
Reference in New Issue
Block a user