From 07fdc714ca533bdd6892259f27820fc0395191d0 Mon Sep 17 00:00:00 2001 From: Zyy Date: Mon, 29 Jun 2026 16:15:46 +0800 Subject: [PATCH] feat: improve usage telemetry and account quota UI Co-authored-by: Codex --- src/admin/accounts/routes.rs | 31 +- src/proxy/dispatch/chat.rs | 20 +- src/proxy/dispatch/responses.rs | 35 ++- src/upstream/transport/client.rs | 49 ++- src/upstream/transport/mod.rs | 4 +- src/upstream/transport/websocket.rs | 56 +++- src/upstream/transport/websocket_pool.rs | 103 +++++- tests/admin/accounts/list.rs | 14 + tests/admin/accounts/quota.rs | 10 + .../dispatch/chat_upstream/chat_routes.rs | 1 + .../dispatch/chat_upstream/responses_http.rs | 1 + tests/upstream/transport/websocket_pool.rs | 20 ++ web/src/components/base/BaseModal.vue | 21 +- web/src/components/base/BaseScrollbar.vue | 56 +++- web/src/components/base/BaseTable/index.vue | 119 +------ .../components/AccountQuotaSummaryCell.vue | 2 +- .../composables/useAccountMutations.ts | 14 +- .../components/UsageRecordDetailModal.vue | 294 +++++++++--------- web/src/views/usage/index.vue | 4 +- 19 files changed, 535 insertions(+), 319 deletions(-) diff --git a/src/admin/accounts/routes.rs b/src/admin/accounts/routes.rs index 6ffa4f4e8..98d9d2336 100644 --- a/src/admin/accounts/routes.rs +++ b/src/admin/accounts/routes.rs @@ -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 { diff --git a/src/proxy/dispatch/chat.rs b/src/proxy/dispatch/chat.rs index 3cedf81e9..573ff3f73 100644 --- a/src/proxy/dispatch/chat.rs +++ b/src/proxy/dispatch/chat.rs @@ -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; diff --git a/src/proxy/dispatch/responses.rs b/src/proxy/dispatch/responses.rs index 7768e8fc4..7c28340e1 100644 --- a/src/proxy/dispatch/responses.rs +++ b/src/proxy/dispatch/responses.rs @@ -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, turn_state_update: Option, + websocket_pool_decision: Option, turn_state: Option, 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) { } } +fn insert_websocket_pool_decision(metadata: &mut Value, decision: Option) { + 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 diff --git a/src/upstream/transport/client.rs b/src/upstream/transport/client.rs index 04229a407..b5a3114a0 100644 --- a/src/upstream/transport/client.rs +++ b/src/upstream/transport/client.rs @@ -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, + /// WebSocket 连接池决策。 + pub websocket_pool_decision: Option, } /// Codex Responses 实际使用的上游传输。 @@ -290,6 +292,8 @@ pub struct CodexBackendStreamingResponse { pub rate_limit_header_updates: Option, /// live stream 期间捕获的 turn-state 更新。 pub turn_state_update: Option, + /// WebSocket 连接池决策。 + pub websocket_pool_decision: Option, } /// 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, +) { + let Some(decision) = decision else { + return; + }; + let rid_short = request_id.chars().take(8).collect::(); + 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 } diff --git a/src/upstream/transport/mod.rs b/src/upstream/transport/mod.rs index 73829f46a..78c03383c 100644 --- a/src/upstream/transport/mod.rs +++ b/src/upstream/transport/mod.rs @@ -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, + }, }; diff --git a/src/upstream/transport/websocket.rs b/src/upstream/transport/websocket.rs index f57bf5554..1c462dac8 100644 --- a/src/upstream/transport/websocket.rs +++ b/src/upstream/transport/websocket.rs @@ -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, + /// WebSocket 连接池决策。 + pub pool_decision: Option, } /// 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, } /// 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, } } diff --git a/src/upstream/transport/websocket_pool.rs b/src/upstream/transport/websocket_pool.rs index 70b46b400..beba42ec1 100644 --- a/src/upstream/transport/websocket_pool.rs +++ b/src/upstream/transport/websocket_pool.rs @@ -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), 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, +} + +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( diff --git a/tests/admin/accounts/list.rs b/tests/admin/accounts/list.rs index 85b234be2..85771f57f 100644 --- a/tests/admin/accounts/list.rs +++ b/tests/admin/accounts/list.rs @@ -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小时限额"); diff --git a/tests/admin/accounts/quota.rs b/tests/admin/accounts/quota.rs index 0157c8088..bd2893fd0 100644 --- a/tests/admin/accounts/quota.rs +++ b/tests/admin/accounts/quota.rs @@ -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" + ); } diff --git a/tests/proxy/dispatch/chat_upstream/chat_routes.rs b/tests/proxy/dispatch/chat_upstream/chat_routes.rs index e04027b8c..4401a6012 100644 --- a/tests/proxy/dispatch/chat_upstream/chat_routes.rs +++ b/tests/proxy/dispatch/chat_upstream/chat_routes.rs @@ -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() diff --git a/tests/proxy/dispatch/chat_upstream/responses_http.rs b/tests/proxy/dispatch/chat_upstream/responses_http.rs index 68c704536..c928dd467 100644 --- a/tests/proxy/dispatch/chat_upstream/responses_http.rs +++ b/tests/proxy/dispatch/chat_upstream/responses_http.rs @@ -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] diff --git a/tests/upstream/transport/websocket_pool.rs b/tests/upstream/transport/websocket_pool.rs index 62fb1e41b..1a6efb693 100644 --- a/tests/upstream/transport/websocket_pool.rs +++ b/tests/upstream/transport/websocket_pool.rs @@ -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); } diff --git a/web/src/components/base/BaseModal.vue b/web/src/components/base/BaseModal.vue index deb984fb1..5a79ed1b9 100644 --- a/web/src/components/base/BaseModal.vue +++ b/web/src/components/base/BaseModal.vue @@ -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({ 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() { -
- +
+ + + +