feat: improve usage telemetry and account quota UI

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-06-29 16:15:46 +08:00
co-authored by Codex
parent 4a5bb52333
commit 07fdc714ca
19 changed files with 535 additions and 319 deletions
+27 -4
View File
@@ -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(&quota_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 {
+13 -7
View File
@@ -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;
+27 -8
View File
@@ -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
+48 -1
View File
@@ -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
}
+3 -1
View File
@@ -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,
},
};
+46 -10
View File
@@ -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,
}
}
+99 -4
View File
@@ -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(
+14
View File
@@ -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小时限额");
+10
View File
@@ -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);
}
+19 -2
View File
@@ -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">
+41 -15
View File
@@ -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
+5 -114
View File
@@ -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>
+2 -2
View File
@@ -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"